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