authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 19:36:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 19:36:10-07:00
log9e1bc36a0843699db54ee28abbdac70584ae8f33
tree8a24568bc0591d8ae3d44a76eba091727aa7fe4c
parent710f6dc417ebf303eede3df3605a6938482d83ab
signature Signed by PGP key B5690EEEBB952194

fix(web): keep the last message visible when the resting composer expands (#9553)

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";
298import { MessagesTimeline } from "./chat/MessagesTimeline";298import { MessagesTimeline } from "./chat/MessagesTimeline";
299import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";299import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";
300import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";300import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";
301import { resolveComposerTimelineInset } from "./composerFooterLayout";
301import { ChatHeader } from "./chat/ChatHeader";302import { ChatHeader } from "./chat/ChatHeader";
302import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";303import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
303import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview";304import { 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]);
49715003
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;
251251
252function useComposerRestingTransition(252function 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(
355357
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%";
387398
388 // The chat view resize-observes the overlay to place the timeline399 // Pinning the overlay at the destination height keeps the resize
389 // inset, the scroll-to-end pill, and the mini player. Pinning the400 // observer quiet for the tween; bottom alignment keeps the animating
390 // overlay at the destination height turns that feedback into one401 // 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's403 // changes remain natural.
393 // stable bottom edge. The pin lasts only for the tween so later404 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 }
438439
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]);
614615
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;
12351253
1236 // Refs the parent needs kept in sync1254 // 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";
4import {4import {
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});
7678
79describe("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
77describe("shouldUseRestingComposerLayout", () => {99describe("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}
5050
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 */
57export 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 */
71export 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
51export function shouldAnimateComposerRestingTransition(input: {81export 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
55the composer loses focus. At wider sizes, scrolling the conversation also rests a focused composer,55the composer loses focus. At wider sizes, scrolling the conversation also rests a focused composer,
56except when scrolling toward the end while already there. When the thread-context strip has room,56except when scrolling toward the end while already there. When the thread-context strip has room,
57the model and mode controls stay available beside the thread context; otherwise they return when the57the model and mode controls stay available beside the thread context; otherwise they return when the
58composer is focused. Focus the composer or start typing to expand it again. New-thread layouts keep58composer is focused. Focus the composer or start typing to expand it again. The conversation keeps
59the full composer. **Settings → General → Collapse composer** chooses which triggers rest it:59the expanded composer's space clear above its last message while the composer rests, so expanding it
60again 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.
6162
62At phone-sized web or desktop window widths, existing threads animate between their compact and63At phone-sized web or desktop window widths, existing threads animate between their compact and