| author | |
| committer | |
| log | a07715c09e2f2b82555ca4221904fd806c770f8e |
| tree | 2a8b12d49d4d8cd687aa83bd79ee0b68bb245a69 |
| parent | 0860cea0c25f34582d03f17ccde23c5dfec64c12 |
| signature | Signed by PGP key B5690EEEBB952194 |
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) { |
| 1661 | 1661 | const composerRestingRef = useRef(false); |
| 1662 | 1662 | const [scrollToEndClearance, setScrollToEndClearance] = useState(0); |
| 1663 | 1663 | const isAtEndRef = useRef(true); |
| 1664 | const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []); | |
| 1664 | const isTimelineAtLogicalEnd = useCallback( | |
| 1665 | () => resolveTimelineIsAtEnd(legendListRef.current?.getState()) ?? isAtEndRef.current, | |
| 1666 | [], | |
| 1667 | ); | |
| 1665 | 1668 | // Whether the timeline's rows extend past the viewport above the composer. |
| 1666 | 1669 | // The composer only rests when there is reading space to give back. |
| 1667 | 1670 | const [timelineOverflows, setTimelineOverflows] = useState(false); |
| ... | ... | @@ -4600,7 +4603,9 @@ export default function ChatView(props: ChatViewProps) { |
| 4600 | 4603 | null, |
| 4601 | 4604 | ); |
| 4602 | 4605 | 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()) { | |
| 4604 | 4609 | cancelTimelineLiveFollowForUserNavigation(); |
| 4605 | 4610 | } |
| 4606 | 4611 | }); |
| ... | ... | @@ -4746,8 +4751,25 @@ export default function ChatView(props: ChatViewProps) { |
| 4746 | 4751 | }; |
| 4747 | 4752 | // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and |
| 4748 | 4753 | // 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. | |
| 4750 | 4756 | 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 | } | |
| 4751 | 4773 | switch (event.key) { |
| 4752 | 4774 | case "PageUp": |
| 4753 | 4775 | case "Home": |
| ... | ... | @@ -4755,12 +4777,20 @@ export default function ChatView(props: ChatViewProps) { |
| 4755 | 4777 | timelineScrollIntentRef.current = "away-from-end"; |
| 4756 | 4778 | if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) { |
| 4757 | 4779 | handleManualNavigation(); |
| 4780 | composerRef.current?.collapseForTimelineScrollKey(event.key); | |
| 4758 | 4781 | } |
| 4759 | 4782 | break; |
| 4760 | 4783 | case "PageDown": |
| 4761 | 4784 | case "End": |
| 4762 | 4785 | case "ArrowDown": |
| 4763 | 4786 | timelineScrollIntentRef.current = "toward-end"; |
| 4787 | if (viewportIsAwayFromEnd()) { | |
| 4788 | handleManualNavigation(); | |
| 4789 | } | |
| 4790 | composerRef.current?.collapseForTimelineScrollKey(event.key); | |
| 4791 | if (isTimelineAtLogicalEnd()) { | |
| 4792 | composerRef.current?.restoreAfterTimelineReachedEnd(); | |
| 4793 | } | |
| 4764 | 4794 | break; |
| 4765 | 4795 | default: |
| 4766 | 4796 | break; |
| ... | ... | @@ -4775,12 +4805,12 @@ export default function ChatView(props: ChatViewProps) { |
| 4775 | 4805 | scrollNode.addEventListener("pointerdown", handlePointerDown, { |
| 4776 | 4806 | passive: true, |
| 4777 | 4807 | }); |
| 4778 | scrollNode.addEventListener("keydown", handleKeyDown); | |
| 4808 | document.addEventListener("keydown", handleKeyDown); | |
| 4779 | 4809 | removeListeners = () => { |
| 4780 | 4810 | scrollNode.removeEventListener("wheel", handleWheel); |
| 4781 | 4811 | scrollNode.removeEventListener("touchmove", handleTouchMove); |
| 4782 | 4812 | scrollNode.removeEventListener("pointerdown", handlePointerDown); |
| 4783 | scrollNode.removeEventListener("keydown", handleKeyDown); | |
| 4813 | document.removeEventListener("keydown", handleKeyDown); | |
| 4784 | 4814 | }; |
| 4785 | 4815 | }); |
| 4786 | 4816 | }; |
| ... | ... | @@ -4792,7 +4822,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4792 | 4822 | } |
| 4793 | 4823 | removeListeners?.(); |
| 4794 | 4824 | }; |
| 4795 | }, [activeThread?.id, timelineRealContentOverflowsViewport]); | |
| 4825 | }, [activeThread?.id, isTimelineAtLogicalEnd, timelineRealContentOverflowsViewport]); | |
| 4796 | 4826 | |
| 4797 | 4827 | const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { |
| 4798 | 4828 | // Anchored-end space can be remeasured when the turn completes. Once the |
| ... | ... | @@ -7957,7 +7987,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7957 | 7987 | ref={attachDraftHeroTransitionGroupRef} |
| 7958 | 7988 | 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)]" |
| 7959 | 7989 | > |
| 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"> | |
| 7961 | 7991 | {isDraftHeroState ? ( |
| 7962 | 7992 | <div className="absolute inset-x-0 bottom-full z-0"> |
| 7963 | 7993 | <div |
apps/web/src/components/chat/ChatComposer.tsx+28-2| ... | ... | @@ -91,6 +91,7 @@ import { ComposerStashBadge } from "./ComposerStashBadge"; |
| 91 | 91 | import { ComposerStashMenu } from "./ComposerStashMenu"; |
| 92 | 92 | import { useComposerMenuState } from "./useComposerMenuState"; |
| 93 | 93 | import { useComposerFocusState } from "./useComposerFocusState"; |
| 94 | import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt"; | |
| 94 | 95 | import { |
| 95 | 96 | ComposerTasksBadge, |
| 96 | 97 | ComposerTasksContent, |
| ... | ... | @@ -210,6 +211,7 @@ import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation |
| 210 | 211 | import { |
| 211 | 212 | createComposerScrollGestureState, |
| 212 | 213 | recordComposerScrollGestureEvent, |
| 214 | shouldCollapseComposerForScrollKey, | |
| 213 | 215 | resetComposerScrollGesture, |
| 214 | 216 | suppressActiveComposerScrollGesture, |
| 215 | 217 | } from "./composerScrollGesture"; |
| ... | ... | @@ -1112,6 +1114,7 @@ export interface ChatComposerHandle { |
| 1112 | 1114 | focusAt: (cursor: number) => void; |
| 1113 | 1115 | /** Expand the desktop composer at the timeline end without taking focus. */ |
| 1114 | 1116 | restoreAfterTimelineReachedEnd: () => void; |
| 1117 | collapseForTimelineScrollKey: (key: string) => void; | |
| 1115 | 1118 | addDroppedFiles: (files: File[]) => void; |
| 1116 | 1119 | insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; |
| 1117 | 1120 | citeAssistantText: ( |
| ... | ... | @@ -1818,6 +1821,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1818 | 1821 | null, |
| 1819 | 1822 | ); |
| 1820 | 1823 | const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null); |
| 1824 | const hasWrappedPrompt = useComposerMultilinePrompt(composerMenuAnchor); | |
| 1825 | const hasMultilinePrompt = prompt.includes("\n") || hasWrappedPrompt; | |
| 1821 | 1826 | const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); |
| 1822 | 1827 | const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false); |
| 1823 | 1828 | const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ |
| ... | ... | @@ -1827,7 +1832,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1827 | 1832 | const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = |
| 1828 | 1833 | usePanelAnimationSettings(); |
| 1829 | 1834 | const isComposerCollapsedMobile = |
| 1830 | isMobileViewport && !forceExpandedOnMobile && !isComposerFocused; | |
| 1835 | isMobileViewport && !forceExpandedOnMobile && !isComposerFocused && !hasMultilinePrompt; | |
| 1831 | 1836 | |
| 1832 | 1837 | // ------------------------------------------------------------------ |
| 1833 | 1838 | // Refs |
| ... | ... | @@ -3715,6 +3720,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3715 | 3720 | isMobileViewport, |
| 3716 | 3721 | isScrollCollapsed: isComposerScrollCollapsed, |
| 3717 | 3722 | hasExpandedChrome: composerHasExpandedChrome, |
| 3723 | hasMultilinePrompt, | |
| 3718 | 3724 | timelineOverflows, |
| 3719 | 3725 | }); |
| 3720 | 3726 | // The relocated controls live in the context strip whenever the composer is |
| ... | ... | @@ -3797,6 +3803,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3797 | 3803 | const canScrollCollapseComposer = |
| 3798 | 3804 | canTrackComposerScrollGesture && |
| 3799 | 3805 | settings.composerCollapseOnScroll && |
| 3806 | !hasMultilinePrompt && | |
| 3800 | 3807 | !composerHasExpandedChrome && |
| 3801 | 3808 | !showInlineTasksBadge; |
| 3802 | 3809 | // Scrolling only has something to collapse while the composer is expanded, |
| ... | ... | @@ -4520,6 +4527,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4520 | 4527 | composerEditorRef.current?.focusAt(cursor); |
| 4521 | 4528 | }, |
| 4522 | 4529 | 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 | }, | |
| 4523 | 4546 | addDroppedFiles: (files: File[]) => { |
| 4524 | 4547 | void addComposerAttachments(files); |
| 4525 | 4548 | focusComposer(); |
| ... | ... | @@ -4664,6 +4687,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4664 | 4687 | planModeUiEnabled, |
| 4665 | 4688 | compactThreadContext, |
| 4666 | 4689 | restoreAfterTimelineReachedEnd, |
| 4690 | getTimelineScrollableNode, | |
| 4691 | isTimelineAtLogicalEnd, | |
| 4692 | setIsComposerScrollCollapsed, | |
| 4667 | 4693 | ], |
| 4668 | 4694 | ); |
| 4669 | 4695 | |
| ... | ... | @@ -5379,7 +5405,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5379 | 5405 | className={cn( |
| 5380 | 5406 | showMobilePendingAnswerActions && "max-sm:pb-11", |
| 5381 | 5407 | 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", | |
| 5383 | 5409 | )} |
| 5384 | 5410 | placeholderClassName={cn( |
| 5385 | 5411 | isComposerResting && |
apps/web/src/components/chat/MessagesTimeline.test.tsx+1| ... | ... | @@ -278,6 +278,7 @@ describe("MessagesTimeline", () => { |
| 278 | 278 | isMobileViewport: false, |
| 279 | 279 | isScrollCollapsed: composer.isComposerScrollCollapsed, |
| 280 | 280 | hasExpandedChrome: false, |
| 281 | hasMultilinePrompt: false, | |
| 281 | 282 | timelineOverflows: true, |
| 282 | 283 | }); |
| 283 | 284 | }); |
apps/web/src/components/chat/composerScrollGesture.test.ts+29| ... | ... | @@ -4,12 +4,41 @@ import { |
| 4 | 4 | createComposerScrollGestureState, |
| 5 | 5 | recordComposerScrollGestureEvent, |
| 6 | 6 | resetComposerScrollGesture, |
| 7 | shouldCollapseComposerForScrollKey, | |
| 7 | 8 | suppressActiveComposerScrollGesture, |
| 8 | 9 | } from "./composerScrollGesture"; |
| 9 | 10 | |
| 10 | 11 | const RESET_MS = 120; |
| 11 | 12 | const THRESHOLD_PX = 24; |
| 12 | 13 | |
| 14 | describe("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 | ||
| 13 | 42 | function record( |
| 14 | 43 | state: ReturnType<typeof createComposerScrollGestureState>, |
| 15 | 44 | now: number, |
apps/web/src/components/chat/composerScrollGesture.ts+19| ... | ... | @@ -4,6 +4,25 @@ export type ComposerScrollGestureState = { |
| 4 | 4 | lastEventAt: number; |
| 5 | 5 | }; |
| 6 | 6 | |
| 7 | export 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 | ||
| 7 | 26 | export function createComposerScrollGestureState(): ComposerScrollGestureState { |
| 8 | 27 | return { |
| 9 | 28 | accumulatedDeltaPx: 0, |
apps/web/src/components/chat/useComposerFocusState.test.tsx+1| ... | ... | @@ -18,6 +18,7 @@ function ComposerProbe() { |
| 18 | 18 | isMobileViewport: false, |
| 19 | 19 | isScrollCollapsed: state.isComposerScrollCollapsed, |
| 20 | 20 | hasExpandedChrome: false, |
| 21 | hasMultilinePrompt: false, | |
| 21 | 22 | timelineOverflows: true, |
| 22 | 23 | }); |
| 23 | 24 | }); |
apps/web/src/components/chat/useComposerMultilinePrompt.test.ts created+50| ... | ... | @@ -0,0 +1,50 @@ |
| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { measureComposerMultilinePrompt } from "./useComposerMultilinePrompt"; | |
| 4 | ||
| 5 | function 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 | ||
| 22 | afterEach(() => vi.unstubAllGlobals()); | |
| 23 | ||
| 24 | describe("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 @@ |
| 1 | import { useLayoutEffect, useState } from "react"; | |
| 2 | ||
| 3 | export 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 | ||
| 23 | export 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", () => { |
| 102 | 102 | isMobileViewport: false, |
| 103 | 103 | isScrollCollapsed: true, |
| 104 | 104 | hasExpandedChrome: false, |
| 105 | hasMultilinePrompt: false, | |
| 105 | 106 | timelineOverflows: true, |
| 106 | 107 | }; |
| 107 | 108 | |
| ... | ... | @@ -128,6 +129,19 @@ describe("shouldUseRestingComposerLayout", () => { |
| 128 | 129 | it("keeps drawers and composer-owned menus expanded", () => { |
| 129 | 130 | expect(shouldUseRestingComposerLayout({ ...resting, hasExpandedChrome: true })).toBe(false); |
| 130 | 131 | }); |
| 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 | ); | |
| 131 | 145 | }); |
| 132 | 146 | |
| 133 | 147 | describe("shouldAnimateComposerRestingTransition", () => { |
apps/web/src/components/composerFooterLayout.ts+4-2| ... | ... | @@ -28,11 +28,12 @@ export function shouldUseRestingComposerLayout(input: { |
| 28 | 28 | isMobileViewport: boolean; |
| 29 | 29 | isScrollCollapsed: boolean; |
| 30 | 30 | hasExpandedChrome: boolean; |
| 31 | hasMultilinePrompt: boolean; | |
| 31 | 32 | /** Whether the timeline has more content than fits above the composer. */ |
| 32 | 33 | timelineOverflows: boolean; |
| 33 | 34 | }): 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 | |
| 36 | 37 | // rows keep their natural height above it while image previews move inline. |
| 37 | 38 | // Banners and the tasks badge dock above the surface, so they are absent |
| 38 | 39 | // too. Whether the context strip can host the relocated controls is |
| ... | ... | @@ -53,6 +54,7 @@ export function shouldUseRestingComposerLayout(input: { |
| 53 | 54 | !input.isMobileViewport && |
| 54 | 55 | input.timelineOverflows && |
| 55 | 56 | input.isScrollCollapsed && |
| 57 | !input.hasMultilinePrompt && | |
| 56 | 58 | !input.hasExpandedChrome |
| 57 | 59 | ); |
| 58 | 60 | } |