| author | |
| committer | |
| log | 9e1bc36a0843699db54ee28abbdac70584ae8f33 |
| tree | 8a24568bc0591d8ae3d44a76eba091727aa7fe4c |
| parent | 710f6dc417ebf303eede3df3605a6938482d83ab |
| signature | Signed by PGP key B5690EEEBB952194 |
Scrolling a long thread to the end with the composer at rest landed flush
against the short composer. The expansion that followed then covered the
last rows, because the timeline reserves only the live overlay height and
does not move for footer growth.
The timeline now keeps the expanded composer's height clear while the
composer rests, so expanding it again changes nothing above the composer.
The composer reports its resting flag from a layout effect and publishes a
fresh overlay height whenever that flag changes, so the reservation is
always computed from a height that belongs to the same layout.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>5 files changed, 133 insertions(+), 24 deletions(-)
apps/web/src/components/ChatView.tsx+38-5| ... | @@ -298,6 +298,7 @@ import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; | ... | @@ -298,6 +298,7 @@ import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| 298 | import { MessagesTimeline } from "./chat/MessagesTimeline"; | 298 | import { MessagesTimeline } from "./chat/MessagesTimeline"; |
| 299 | import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; | 299 | import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; |
| 300 | import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; | 300 | import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; |
| 301 | import { resolveComposerTimelineInset } from "./composerFooterLayout"; | ||
| 301 | import { ChatHeader } from "./chat/ChatHeader"; | 302 | import { ChatHeader } from "./chat/ChatHeader"; |
| 302 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; | 303 | import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; |
| 303 | import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | 304 | import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| ... | @@ -1628,6 +1629,12 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1628,6 +1629,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 1628 | const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null); | 1629 | const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null); |
| 1629 | const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); | 1630 | const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); |
| 1630 | const composerOverlayHeightRef = useRef(0); | 1631 | const composerOverlayHeightRef = useRef(0); |
| 1632 | // Space the timeline keeps clear above its end. Tracks the overlay while the | ||
| 1633 | // composer is expanded and holds that height while it rests, so the resting | ||
| 1634 | // composer never exposes rows that its expansion will cover. | ||
| 1635 | const [composerTimelineInset, setComposerTimelineInset] = useState(0); | ||
| 1636 | const composerTimelineInsetRef = useRef(0); | ||
| 1637 | const composerRestingRef = useRef(false); | ||
| 1631 | const [scrollToEndClearance, setScrollToEndClearance] = useState(0); | 1638 | const [scrollToEndClearance, setScrollToEndClearance] = useState(0); |
| 1632 | const isAtEndRef = useRef(true); | 1639 | const isAtEndRef = useRef(true); |
| 1633 | const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []); | 1640 | const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []); |
| ... | @@ -4459,11 +4466,11 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4459,11 +4466,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 4459 | return getAnchoredTurnMetrics({ | 4466 | return getAnchoredTurnMetrics({ |
| 4460 | state, | 4467 | state, |
| 4461 | anchorIndex, | 4468 | anchorIndex, |
| 4462 | composerOverlayHeight, | 4469 | composerOverlayHeight: composerTimelineInset, |
| 4463 | anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, | 4470 | anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, |
| 4464 | }); | 4471 | }); |
| 4465 | }, | 4472 | }, |
| 4466 | [composerOverlayHeight], | 4473 | [composerTimelineInset], |
| 4467 | ); | 4474 | ); |
| 4468 | const timelineRealContentOverflowsViewport = useCallback( | 4475 | const timelineRealContentOverflowsViewport = useCallback( |
| 4469 | (list?: LegendListRef | null) => { | 4476 | (list?: LegendListRef | null) => { |
| ... | @@ -4488,11 +4495,11 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4488,11 +4495,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 4488 | const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); | 4495 | const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); |
| 4489 | const visibleScrollLength = Math.max( | 4496 | const visibleScrollLength = Math.max( |
| 4490 | 0, | 4497 | 0, |
| 4491 | (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_TIMELINE_ANCHOR_OFFSET, | 4498 | (state.scrollLength ?? 0) - composerTimelineInset - CHAT_TIMELINE_ANCHOR_OFFSET, |
| 4492 | ); | 4499 | ); |
| 4493 | return realContentBottom > visibleScrollLength; | 4500 | return realContentBottom > visibleScrollLength; |
| 4494 | }, | 4501 | }, |
| 4495 | [composerOverlayHeight], | 4502 | [composerTimelineInset], |
| 4496 | ); | 4503 | ); |
| 4497 | const pageScrollControllerRef = useRef<ReturnType<typeof createPageScrollController> | null>( | 4504 | const pageScrollControllerRef = useRef<ReturnType<typeof createPageScrollController> | null>( |
| 4498 | null, | 4505 | null, |
| ... | @@ -4964,10 +4971,35 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4964,10 +4971,35 @@ function ChatViewContent(props: ChatViewProps) { |
| 4964 | composerOverlayHeightRef.current = nextHeight; | 4971 | composerOverlayHeightRef.current = nextHeight; |
| 4965 | setComposerOverlayHeight(nextHeight); | 4972 | setComposerOverlayHeight(nextHeight); |
| 4966 | } | 4973 | } |
| 4974 | const nextInset = resolveComposerTimelineInset({ | ||
| 4975 | currentInset: composerTimelineInsetRef.current, | ||
| 4976 | overlayHeight: nextHeight, | ||
| 4977 | isResting: composerRestingRef.current, | ||
| 4978 | }); | ||
| 4979 | if (composerTimelineInsetRef.current !== nextInset) { | ||
| 4980 | composerTimelineInsetRef.current = nextInset; | ||
| 4981 | setComposerTimelineInset(nextInset); | ||
| 4982 | } | ||
| 4967 | setScrollToEndClearance((currentClearance) => | 4983 | setScrollToEndClearance((currentClearance) => |
| 4968 | currentClearance === nextHeight ? currentClearance : nextHeight, | 4984 | currentClearance === nextHeight ? currentClearance : nextHeight, |
| 4969 | ); | 4985 | ); |
| 4970 | }, []); | 4986 | }, []); |
| 4987 | // The composer reports its resting flag from a layout effect, which runs | ||
| 4988 | // before this component's own layout effects and before any resize | ||
| 4989 | // observation, so every measurement below sees the flag for its layout. | ||
| 4990 | // Only the flag is stored here: the stored height still belongs to the | ||
| 4991 | // previous layout, and the composer publishes the new layout's height | ||
| 4992 | // itself once it has measured it. | ||
| 4993 | const onComposerRestingChange = useCallback((resting: boolean) => { | ||
| 4994 | composerRestingRef.current = resting; | ||
| 4995 | }, []); | ||
| 4996 | // A held reservation belongs to the previous thread's draft. Rebuild it from | ||
| 4997 | // this thread's overlay so a tall draft elsewhere does not pad this one. | ||
| 4998 | useLayoutEffect(() => { | ||
| 4999 | if (!composerOverlayElement) return; | ||
| 5000 | composerTimelineInsetRef.current = 0; | ||
| 5001 | publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); | ||
| 5002 | }, [activeThreadKey, composerOverlayElement, publishComposerOverlayHeight]); | ||
| 4971 | 5003 | ||
| 4972 | useLayoutEffect(() => { | 5004 | useLayoutEffect(() => { |
| 4973 | if (!composerOverlayElement) return; | 5005 | if (!composerOverlayElement) return; |
| ... | @@ -7775,7 +7807,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -7775,7 +7807,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7775 | } | 7807 | } |
| 7776 | anchorMessageId={timelineAnchorMessageId} | 7808 | anchorMessageId={timelineAnchorMessageId} |
| 7777 | onAnchorReady={onTimelineAnchorReady} | 7809 | onAnchorReady={onTimelineAnchorReady} |
| 7778 | contentInsetEndAdjustment={composerOverlayHeight} | 7810 | contentInsetEndAdjustment={composerTimelineInset} |
| 7779 | liveFollowEnabled={timelineLiveFollowEnabled} | 7811 | liveFollowEnabled={timelineLiveFollowEnabled} |
| 7780 | onIsAtEndChange={onIsAtEndChange} | 7812 | onIsAtEndChange={onIsAtEndChange} |
| 7781 | onManualNavigation={cancelTimelineLiveFollowForUserNavigation} | 7813 | onManualNavigation={cancelTimelineLiveFollowForUserNavigation} |
| ... | @@ -7923,6 +7955,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -7923,6 +7955,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 7923 | getTimelineScrollableNode={getTimelineScrollableNode} | 7955 | getTimelineScrollableNode={getTimelineScrollableNode} |
| 7924 | isTimelineAtLogicalEnd={isTimelineAtLogicalEnd} | 7956 | isTimelineAtLogicalEnd={isTimelineAtLogicalEnd} |
| 7925 | onComposerOverlayHeightChange={publishComposerOverlayHeight} | 7957 | onComposerOverlayHeightChange={publishComposerOverlayHeight} |
| 7958 | onRestingChange={onComposerRestingChange} | ||
| 7926 | promptRef={promptRef} | 7959 | promptRef={promptRef} |
| 7927 | composerImagesRef={composerImagesRef} | 7960 | composerImagesRef={composerImagesRef} |
| 7928 | composerFilesRef={composerFilesRef} | 7961 | composerFilesRef={composerFilesRef} |
apps/web/src/components/chat/ChatComposer.tsx+40-17| ... | @@ -251,12 +251,14 @@ const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4; | ... | @@ -251,12 +251,14 @@ const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4; |
| 251 | 251 | ||
| 252 | function useComposerRestingTransition( | 252 | function useComposerRestingTransition( |
| 253 | isCollapsed: boolean, | 253 | isCollapsed: boolean, |
| 254 | isResting: boolean, | ||
| 254 | restingControlsRef: React.RefObject<HTMLDivElement | null>, | 255 | restingControlsRef: React.RefObject<HTMLDivElement | null>, |
| 255 | onOverlayHeightChange: (height: number) => void, | 256 | onOverlayHeightChange: (height: number) => void, |
| 256 | ) { | 257 | ) { |
| 257 | const elementRef = useRef<HTMLDivElement>(null); | 258 | const elementRef = useRef<HTMLDivElement>(null); |
| 258 | const isCollapsedRef = useRef(isCollapsed); | 259 | const isCollapsedRef = useRef(isCollapsed); |
| 259 | const previousCollapsedRef = useRef(isCollapsed); | 260 | const previousCollapsedRef = useRef(isCollapsed); |
| 261 | const previousRestingRef = useRef(isResting); | ||
| 260 | const previousHeightRef = useRef<number | null>(null); | 262 | const previousHeightRef = useRef<number | null>(null); |
| 261 | const previousContentOffsetsRef = useRef<{ | 263 | const previousContentOffsetsRef = useRef<{ |
| 262 | promptFromTop: number | null; | 264 | promptFromTop: number | null; |
| ... | @@ -355,6 +357,15 @@ function useComposerRestingTransition( | ... | @@ -355,6 +357,15 @@ function useComposerRestingTransition( |
| 355 | 357 | ||
| 356 | const nextRect = element.getBoundingClientRect(); | 358 | const nextRect = element.getBoundingClientRect(); |
| 357 | const nextHeight = nextRect.height; | 359 | const nextHeight = nextRect.height; |
| 360 | // The chat view resize-observes the overlay to place the timeline | ||
| 361 | // inset, the scroll-to-end pill, and the mini player. Publishing the | ||
| 362 | // destination height here turns that feedback into one update instead | ||
| 363 | // of a ChatView re-render on every animation frame. | ||
| 364 | const overlay = element.closest<HTMLElement>('[data-chat-composer-overlay="true"]'); | ||
| 365 | const overlayHeight = overlay?.getBoundingClientRect().height ?? null; | ||
| 366 | if (overlayHeight !== null) { | ||
| 367 | onOverlayHeightChange(overlayHeight); | ||
| 368 | } | ||
| 358 | const nextPromptRect = prompt?.getBoundingClientRect() ?? null; | 369 | const nextPromptRect = prompt?.getBoundingClientRect() ?? null; |
| 359 | const nextPromptTop = nextPromptRect?.top ?? null; | 370 | const nextPromptTop = nextPromptRect?.top ?? null; |
| 360 | const nextActionTop = action?.getBoundingClientRect().top ?? null; | 371 | const nextActionTop = action?.getBoundingClientRect().top ?? null; |
| ... | @@ -385,18 +396,13 @@ function useComposerRestingTransition( | ... | @@ -385,18 +396,13 @@ function useComposerRestingTransition( |
| 385 | element.style.overflow = "clip"; | 396 | element.style.overflow = "clip"; |
| 386 | surface.style.height = "100%"; | 397 | surface.style.height = "100%"; |
| 387 | 398 | ||
| 388 | // The chat view resize-observes the overlay to place the timeline | 399 | // Pinning the overlay at the destination height keeps the resize |
| 389 | // inset, the scroll-to-end pill, and the mini player. Pinning the | 400 | // observer quiet for the tween; bottom alignment keeps the animating |
| 390 | // overlay at the destination height turns that feedback into one | 401 | // surface glued to the overlay's stable bottom edge. The pin lasts |
| 391 | // update instead of a ChatView re-render on every animation frame; | 402 | // only for the tween so later attachment, thread, font, and viewport |
| 392 | // bottom alignment keeps the animating surface glued to the overlay's | 403 | // changes remain natural. |
| 393 | // stable bottom edge. The pin lasts only for the tween so later | 404 | if (overlay && overlayHeight !== null) { |
| 394 | // attachment, thread, font, and viewport changes remain natural. | 405 | overlay.style.height = `${String(overlayHeight)}px`; |
| 395 | const overlay = element.closest<HTMLElement>('[data-chat-composer-overlay="true"]'); | ||
| 396 | let pinnedOverlayHeight: number | null = null; | ||
| 397 | if (overlay) { | ||
| 398 | pinnedOverlayHeight = overlay.getBoundingClientRect().height; | ||
| 399 | overlay.style.height = `${String(pinnedOverlayHeight)}px`; | ||
| 400 | overlay.style.display = "flex"; | 406 | overlay.style.display = "flex"; |
| 401 | overlay.style.flexDirection = "column"; | 407 | overlay.style.flexDirection = "column"; |
| 402 | overlay.style.justifyContent = "flex-end"; | 408 | overlay.style.justifyContent = "flex-end"; |
| ... | @@ -430,11 +436,6 @@ function useComposerRestingTransition( | ... | @@ -430,11 +436,6 @@ function useComposerRestingTransition( |
| 430 | ); | 436 | ); |
| 431 | animationRef.current = animation; | 437 | animationRef.current = animation; |
| 432 | animationTargetHeightRef.current = nextHeight; | 438 | animationTargetHeightRef.current = nextHeight; |
| 433 | // Publish the destination overlay geometry in the same layout pass; | ||
| 434 | // ResizeObserver remains the fallback for non-transition changes. | ||
| 435 | if (pinnedOverlayHeight !== null) { | ||
| 436 | onOverlayHeightChange(pinnedOverlayHeight); | ||
| 437 | } | ||
| 438 | 439 | ||
| 439 | const animatedRect = element.getBoundingClientRect(); | 440 | const animatedRect = element.getBoundingClientRect(); |
| 440 | const previousPromptTop = | 441 | const previousPromptTop = |
| ... | @@ -612,6 +613,18 @@ function useComposerRestingTransition( | ... | @@ -612,6 +613,18 @@ function useComposerRestingTransition( |
| 612 | }; | 613 | }; |
| 613 | }, [isCollapsed, transitionToCurrentGeometry]); | 614 | }, [isCollapsed, transitionToCurrentGeometry]); |
| 614 | 615 | ||
| 616 | // The resting flag can change while the collapsed layout stays the same, | ||
| 617 | // for example when an unfocused thread crosses the phone breakpoint. The | ||
| 618 | // chat view pairs overlay heights with that flag, so republish the natural | ||
| 619 | // height for the new flag. A transition in flight publishes its own. | ||
| 620 | useLayoutEffect(() => { | ||
| 621 | if (previousRestingRef.current === isResting) return; | ||
| 622 | previousRestingRef.current = isResting; | ||
| 623 | if (animationRef.current) return; | ||
| 624 | const overlay = elementRef.current?.closest<HTMLElement>('[data-chat-composer-overlay="true"]'); | ||
| 625 | if (overlay) onOverlayHeightChange(overlay.getBoundingClientRect().height); | ||
| 626 | }, [isResting, onOverlayHeightChange]); | ||
| 627 | |||
| 615 | useLayoutEffect(() => { | 628 | useLayoutEffect(() => { |
| 616 | const element = elementRef.current; | 629 | const element = elementRef.current; |
| 617 | if (!element || typeof ResizeObserver === "undefined") return; | 630 | if (!element || typeof ResizeObserver === "undefined") return; |
| ... | @@ -1232,6 +1245,11 @@ export interface ChatComposerProps { | ... | @@ -1232,6 +1245,11 @@ export interface ChatComposerProps { |
| 1232 | getTimelineScrollableNode: () => HTMLElement | null; | 1245 | getTimelineScrollableNode: () => HTMLElement | null; |
| 1233 | isTimelineAtLogicalEnd: () => boolean; | 1246 | isTimelineAtLogicalEnd: () => boolean; |
| 1234 | onComposerOverlayHeightChange: (height: number) => void; | 1247 | onComposerOverlayHeightChange: (height: number) => void; |
| 1248 | /** | ||
| 1249 | * Whether the desktop resting layout is active. Reported from a layout | ||
| 1250 | * effect, so it is current before the chat view measures the overlay. | ||
| 1251 | */ | ||
| 1252 | onRestingChange: (resting: boolean) => void; | ||
| 1235 | 1253 | ||
| 1236 | // Refs the parent needs kept in sync | 1254 | // Refs the parent needs kept in sync |
| 1237 | promptRef: React.RefObject<string>; | 1255 | promptRef: React.RefObject<string>; |
| ... | @@ -1336,6 +1354,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1336,6 +1354,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1336 | getTimelineScrollableNode, | 1354 | getTimelineScrollableNode, |
| 1337 | isTimelineAtLogicalEnd, | 1355 | isTimelineAtLogicalEnd, |
| 1338 | onComposerOverlayHeightChange, | 1356 | onComposerOverlayHeightChange, |
| 1357 | onRestingChange, | ||
| 1339 | promptRef, | 1358 | promptRef, |
| 1340 | composerRef, | 1359 | composerRef, |
| 1341 | composerImagesRef, | 1360 | composerImagesRef, |
| ... | @@ -3584,6 +3603,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3584,6 +3603,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3584 | useLayoutEffect(() => { | 3603 | useLayoutEffect(() => { |
| 3585 | onRestingControlsVisibilityChange(composerControlsVisibleInStrip); | 3604 | onRestingControlsVisibilityChange(composerControlsVisibleInStrip); |
| 3586 | }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]); | 3605 | }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]); |
| 3606 | useLayoutEffect(() => { | ||
| 3607 | onRestingChange(isComposerResting); | ||
| 3608 | }, [isComposerResting, onRestingChange]); | ||
| 3587 | const restingImagePreviewCounts = getRestingComposerImagePreviewCounts( | 3609 | const restingImagePreviewCounts = getRestingComposerImagePreviewCounts( |
| 3588 | standaloneComposerImages.length, | 3610 | standaloneComposerImages.length, |
| 3589 | ); | 3611 | ); |
| ... | @@ -3639,6 +3661,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3639,6 +3661,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3639 | ) : null; | 3661 | ) : null; |
| 3640 | const composerMainSurfaceRef = useComposerRestingTransition( | 3662 | const composerMainSurfaceRef = useComposerRestingTransition( |
| 3641 | composerControlsInStrip, | 3663 | composerControlsInStrip, |
| 3664 | isComposerResting, | ||
| 3642 | restingComposerControlsRef, | 3665 | restingComposerControlsRef, |
| 3643 | onComposerOverlayHeightChange, | 3666 | onComposerOverlayHeightChange, |
| 3644 | ); | 3667 | ); |
apps/web/src/components/composerFooterLayout.test.ts+22| ... | @@ -4,7 +4,9 @@ import { resolveContextStripLabelsCompact } from "./BranchToolbar.logic"; | ... | @@ -4,7 +4,9 @@ import { resolveContextStripLabelsCompact } from "./BranchToolbar.logic"; |
| 4 | import { | 4 | import { |
| 5 | COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, | 5 | COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, |
| 6 | COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, | 6 | COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, |
| 7 | COMPOSER_RESTING_EXPANSION_MIN_PX, | ||
| 7 | getRestingComposerImagePreviewCounts, | 8 | getRestingComposerImagePreviewCounts, |
| 9 | resolveComposerTimelineInset, | ||
| 8 | resolveRestingComposerControlsLayout, | 10 | resolveRestingComposerControlsLayout, |
| 9 | resolveRestingComposerControlsNaturalWidth, | 11 | resolveRestingComposerControlsNaturalWidth, |
| 10 | shouldAnimateComposerRestingTransition, | 12 | shouldAnimateComposerRestingTransition, |
| ... | @@ -74,6 +76,26 @@ describe("shouldUseCompactComposerPrimaryActions", () => { | ... | @@ -74,6 +76,26 @@ describe("shouldUseCompactComposerPrimaryActions", () => { |
| 74 | }); | 76 | }); |
| 75 | }); | 77 | }); |
| 76 | 78 | ||
| 79 | describe("resolveComposerTimelineInset", () => { | ||
| 80 | it("follows the expanded overlay height", () => { | ||
| 81 | expect( | ||
| 82 | resolveComposerTimelineInset({ currentInset: 160, overlayHeight: 140, isResting: false }), | ||
| 83 | ).toBe(140); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("keeps a larger expanded reservation while resting", () => { | ||
| 87 | expect( | ||
| 88 | resolveComposerTimelineInset({ currentInset: 200, overlayHeight: 60, isResting: true }), | ||
| 89 | ).toBe(200); | ||
| 90 | }); | ||
| 91 | |||
| 92 | it("reserves the empty expansion when no larger height is known", () => { | ||
| 93 | expect( | ||
| 94 | resolveComposerTimelineInset({ currentInset: 0, overlayHeight: 60, isResting: true }), | ||
| 95 | ).toBe(60 + COMPOSER_RESTING_EXPANSION_MIN_PX); | ||
| 96 | }); | ||
| 97 | }); | ||
| 98 | |||
| 77 | describe("shouldUseRestingComposerLayout", () => { | 99 | describe("shouldUseRestingComposerLayout", () => { |
| 78 | const resting = { | 100 | const resting = { |
| 79 | isExistingThread: true, | 101 | isExistingThread: true, |
apps/web/src/components/composerFooterLayout.ts+30| ... | @@ -48,6 +48,36 @@ export function shouldUseRestingComposerLayout(input: { | ... | @@ -48,6 +48,36 @@ export function shouldUseRestingComposerLayout(input: { |
| 48 | return input.isExistingThread && !input.isMobileViewport && collapsed && !input.hasExpandedChrome; | 48 | return input.isExistingThread && !input.isMobileViewport && collapsed && !input.hasExpandedChrome; |
| 49 | } | 49 | } |
| 50 | 50 | ||
| 51 | /** | ||
| 52 | * How much taller the empty expanded composer is than its resting row on | ||
| 53 | * desktop widths, from the layout classes in ChatComposer: the body loses | ||
| 54 | * 8px of top padding, the prompt clamps from min-h-17.5 (70px) to 32px, and | ||
| 55 | * the 48px footer leaves flow. | ||
| 56 | */ | ||
| 57 | export const COMPOSER_RESTING_EXPANSION_MIN_PX = 94; | ||
| 58 | |||
| 59 | /** | ||
| 60 | * The space the timeline reserves at its end for the composer overlay. | ||
| 61 | * | ||
| 62 | * The overlay is measured live, but a resting composer is much shorter than | ||
| 63 | * an expanded one. Reserving only the resting height lets a scroll to the end | ||
| 64 | * land flush against the short composer, and the expansion that follows then | ||
| 65 | * covers the last rows because the timeline never moves for footer growth. | ||
| 66 | * While resting, the reservation keeps the last expanded height, or at least | ||
| 67 | * the resting height plus the empty expansion, so expanding again changes | ||
| 68 | * nothing above the composer. An expanded measurement is authoritative and | ||
| 69 | * may shrink it. | ||
| 70 | */ | ||
| 71 | export function resolveComposerTimelineInset(input: { | ||
| 72 | currentInset: number; | ||
| 73 | overlayHeight: number; | ||
| 74 | isResting: boolean; | ||
| 75 | }): number { | ||
| 76 | return input.isResting | ||
| 77 | ? Math.max(input.currentInset, input.overlayHeight + COMPOSER_RESTING_EXPANSION_MIN_PX) | ||
| 78 | : input.overlayHeight; | ||
| 79 | } | ||
| 80 | |||
| 51 | export function shouldAnimateComposerRestingTransition(input: { | 81 | export function shouldAnimateComposerRestingTransition(input: { |
| 52 | hasCompletedInitialLayout: boolean; | 82 | hasCompletedInitialLayout: boolean; |
| 53 | stateChanged: boolean; | 83 | stateChanged: boolean; |
docs/user/composer.md+3-2| ... | @@ -55,8 +55,9 @@ On web and desktop, an existing thread settles its composer into a single-line r | ... | @@ -55,8 +55,9 @@ On web and desktop, an existing thread settles its composer into a single-line r |
| 55 | the composer loses focus. At wider sizes, scrolling the conversation also rests a focused composer, | 55 | the composer loses focus. At wider sizes, scrolling the conversation also rests a focused composer, |
| 56 | except when scrolling toward the end while already there. When the thread-context strip has room, | 56 | except when scrolling toward the end while already there. When the thread-context strip has room, |
| 57 | the model and mode controls stay available beside the thread context; otherwise they return when the | 57 | the model and mode controls stay available beside the thread context; otherwise they return when the |
| 58 | composer is focused. Focus the composer or start typing to expand it again. New-thread layouts keep | 58 | composer is focused. Focus the composer or start typing to expand it again. The conversation keeps |
| 59 | the full composer. **Settings → General → Collapse composer** chooses which triggers rest it: | 59 | the expanded composer's space clear above its last message while the composer rests, so expanding it |
| 60 | again never covers what you scrolled to. New-thread layouts keep the full composer. **Settings → General → Collapse composer** chooses which triggers rest it: | ||
| 60 | **On unfocus**, **On scroll**, both, or neither. With neither selected the composer stays expanded. | 61 | **On unfocus**, **On scroll**, both, or neither. With neither selected the composer stays expanded. |
| 61 | 62 | ||
| 62 | At phone-sized web or desktop window widths, existing threads animate between their compact and | 63 | At phone-sized web or desktop window widths, existing threads animate between their compact and |