authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 17:31:50-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 17:31:50-07:00
loga12589dc0bec60b498e72bdd2190871ee6b5c80d
tree7f4befb787f313a220e155161a63a9ffaa282a91
parent7112697e8be2d6f231726a91eb565e00ddbeebec
signature Signed by PGP key B5690EEEBB952194

fix(web): stop collapsing the composer when it loses focus (#10437)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

14 files changed, 63 insertions(+), 338 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts-1
...@@ -27,7 +27,6 @@ const clientSettings: ClientSettings = {...@@ -27,7 +27,6 @@ const clientSettings: ClientSettings = {
27 confirmThreadDelete: false,27 confirmThreadDelete: false,
28 confirmThreadUnpin: false,28 confirmThreadUnpin: false,
29 contextWindowMeterEnabled: false,29 contextWindowMeterEnabled: false,
30 composerCollapseOnBlur: false,
31 composerCollapseOnScroll: true,30 composerCollapseOnScroll: true,
32 dismissedProviderUpdateNotificationKeys: [],31 dismissedProviderUpdateNotificationKeys: [],
33 diffIgnoreWhitespace: true,32 diffIgnoreWhitespace: true,
apps/web/src/components/chat/ChatComposer.tsx+12-118
...@@ -213,7 +213,6 @@ import {...@@ -213,7 +213,6 @@ import {
213 resetComposerScrollGesture,213 resetComposerScrollGesture,
214 suppressActiveComposerScrollGesture,214 suppressActiveComposerScrollGesture,
215} from "./composerScrollGesture";215} from "./composerScrollGesture";
216import { selectionHoldsComposerOpen } from "./composerSelectionHold";
217import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";216import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
218217
219function ComposerVideoThumbnail({ file }: { file: File }) {218function ComposerVideoThumbnail({ file }: { file: File }) {
...@@ -1813,7 +1812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1813,7 +1812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1813 isComposerScrollCollapsed,1812 isComposerScrollCollapsed,
1814 setIsComposerScrollCollapsed,1813 setIsComposerScrollCollapsed,
1815 restoreAfterTimelineReachedEnd,1814 restoreAfterTimelineReachedEnd,
1816 } = useComposerFocusState(isMobileViewport);1815 } = useComposerFocusState();
1817 const [composerSubmissionError, setComposerSubmissionError] = useState<string | null>(null);1816 const [composerSubmissionError, setComposerSubmissionError] = useState<string | null>(null);
1818 const [providerInputSubmissionError, setProviderInputSubmissionError] = useState<string | null>(1817 const [providerInputSubmissionError, setProviderInputSubmissionError] = useState<string | null>(
1819 null,1818 null,
...@@ -1847,8 +1846,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1847,8 +1846,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1847 const mobileComposerExpandFrameRef = useRef<number | null>(null);1846 const mobileComposerExpandFrameRef = useRef<number | null>(null);
1848 const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);1847 const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);
1849 const mobileComposerExpandInFlightRef = useRef(false);1848 const mobileComposerExpandInFlightRef = useRef(false);
1850 const desktopOutsidePointerInFlightRef = useRef(false);
1851 const desktopOutsidePointerReleaseTimeoutRef = useRef<number | null>(null);
1852 const composerScrollCollapseTimeoutRef = useRef<number | null>(null);1849 const composerScrollCollapseTimeoutRef = useRef<number | null>(null);
1853 const composerScrollCollapseEligibleRef = useRef(false);1850 const composerScrollCollapseEligibleRef = useRef(false);
1854 const windowRefocusInFlightRef = useRef(false);1851 const windowRefocusInFlightRef = useRef(false);
...@@ -3716,10 +3713,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3716,10 +3713,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3716 const isComposerResting = shouldUseRestingComposerLayout({3713 const isComposerResting = shouldUseRestingComposerLayout({
3717 isExistingThread: routeKind === "server" && activeThreadId !== null,3714 isExistingThread: routeKind === "server" && activeThreadId !== null,
3718 isMobileViewport,3715 isMobileViewport,
3719 isFocused: isComposerFocused,
3720 isScrollCollapsed: isComposerScrollCollapsed,3716 isScrollCollapsed: isComposerScrollCollapsed,
3721 hasExpandedChrome: composerHasExpandedChrome,3717 hasExpandedChrome: composerHasExpandedChrome,
3722 collapseOnBlur: settings.composerCollapseOnBlur,
3723 timelineOverflows,3718 timelineOverflows,
3724 });3719 });
3725 // The relocated controls live in the context strip whenever the composer is3720 // The relocated controls live in the context strip whenever the composer is
...@@ -3804,9 +3799,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3804,9 +3799,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3804 settings.composerCollapseOnScroll &&3799 settings.composerCollapseOnScroll &&
3805 !composerHasExpandedChrome &&3800 !composerHasExpandedChrome &&
3806 !showInlineTasksBadge;3801 !showInlineTasksBadge;
3807 // Scrolling only has something to collapse while the composer is expanded.3802 // Scrolling only has something to collapse while the composer is expanded,
3808 // With blur collapse off that includes an unfocused composer, so the wheel3803 // focused or not, so the wheel handler keys off the resting state rather
3809 // handler keys off this rather than editor focus.3804 // than editor focus.
3810 composerScrollCollapseEligibleRef.current = canScrollCollapseComposer && !isComposerResting;3805 composerScrollCollapseEligibleRef.current = canScrollCollapseComposer && !isComposerResting;
38113806
3812 useEffect(() => {3807 useEffect(() => {
...@@ -4455,8 +4450,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4455,8 +4450,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4455 const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => {4450 const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => {
4456 void onImplementPlanInNewThread();4451 void onImplementPlanInNewThread();
4457 }, [onImplementPlanInNewThread]);4452 }, [onImplementPlanInNewThread]);
4453 // The phone composer collapses when the editor loses focus. Desktop only
4454 // rests on a timeline scroll, so losing focus there changes nothing.
4458 const scheduleComposerCollapseCheck = useCallback(() => {4455 const scheduleComposerCollapseCheck = useCallback(() => {
4459 if (isMobileViewport && mobileComposerExpandInFlightRef.current) {4456 if (!isMobileViewport || mobileComposerExpandInFlightRef.current) {
4460 return;4457 return;
4461 }4458 }
4462 if (composerBlurFrameRef.current !== null) {4459 if (composerBlurFrameRef.current !== null) {
...@@ -4464,10 +4461,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4464,10 +4461,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4464 }4461 }
4465 composerBlurFrameRef.current = window.requestAnimationFrame(() => {4462 composerBlurFrameRef.current = window.requestAnimationFrame(() => {
4466 composerBlurFrameRef.current = null;4463 composerBlurFrameRef.current = null;
4467 if (isMobileViewport && mobileComposerExpandInFlightRef.current) {4464 if (mobileComposerExpandInFlightRef.current) {
4468 return;
4469 }
4470 if (!isMobileViewport && desktopOutsidePointerInFlightRef.current) {
4471 return;4465 return;
4472 }4466 }
4473 const composerSurface = composerSurfaceRef.current;4467 const composerSurface = composerSurfaceRef.current;
...@@ -4483,108 +4477,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4483,108 +4477,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4483 ) {4477 ) {
4484 return;4478 return;
4485 }4479 }
4486 if (
4487 !isMobileViewport &&
4488 selectionHoldsComposerOpen(window.getSelection(), getTimelineScrollableNode())
4489 ) {
4490 // The check runs again once the selection clears.
4491 return;
4492 }
4493 setIsComposerFocused(false);4480 setIsComposerFocused(false);
4494 });4481 });
4495 }, [getTimelineScrollableNode, isMobileViewport, setIsComposerFocused]);4482 }, [isMobileViewport, setIsComposerFocused]);
4496
4497 // A held collapse settles when the selection goes away, whether the user
4498 // clicked elsewhere, pressed Escape, or used the selection toolbar.
4499 useEffect(() => {
4500 if (isMobileViewport || !isComposerFocused) return;
4501 let wasHolding = false;
4502 const handleSelectionChange = () => {
4503 const holding = selectionHoldsComposerOpen(
4504 window.getSelection(),
4505 getTimelineScrollableNode(),
4506 );
4507 if (wasHolding && !holding) {
4508 scheduleComposerCollapseCheck();
4509 }
4510 wasHolding = holding;
4511 };
4512 document.addEventListener("selectionchange", handleSelectionChange);
4513 return () => {
4514 document.removeEventListener("selectionchange", handleSelectionChange);
4515 };
4516 }, [
4517 getTimelineScrollableNode,
4518 isComposerFocused,
4519 isMobileViewport,
4520 scheduleComposerCollapseCheck,
4521 ]);
4522
4523 useEffect(() => {
4524 if (isMobileViewport || !isComposerFocused) return;
4525
4526 const isInsideDesktopComposerFocusScope = (target: EventTarget | null) =>
4527 Boolean(
4528 target instanceof Node &&
4529 (composerFormRef.current?.contains(target) || isInsideRestingComposerControlScope(target)),
4530 );
4531 const handleFocusIn = (event: FocusEvent) => {
4532 if (!isInsideDesktopComposerFocusScope(event.target)) {
4533 if (desktopOutsidePointerInFlightRef.current) {
4534 return;
4535 }
4536 setIsComposerFocused(false);
4537 }
4538 };
4539 const handlePointerDown = (event: PointerEvent) => {
4540 if (!isInsideDesktopComposerFocusScope(event.target)) {
4541 desktopOutsidePointerInFlightRef.current = true;
4542 if (desktopOutsidePointerReleaseTimeoutRef.current !== null) {
4543 window.clearTimeout(desktopOutsidePointerReleaseTimeoutRef.current);
4544 desktopOutsidePointerReleaseTimeoutRef.current = null;
4545 }
4546 }
4547 };
4548 const finishOutsidePointerInteraction = () => {
4549 desktopOutsidePointerInFlightRef.current = false;
4550 if (desktopOutsidePointerReleaseTimeoutRef.current !== null) {
4551 window.clearTimeout(desktopOutsidePointerReleaseTimeoutRef.current);
4552 desktopOutsidePointerReleaseTimeoutRef.current = null;
4553 }
4554 scheduleComposerCollapseCheck();
4555 };
4556 const handlePointerUp = () => {
4557 if (!desktopOutsidePointerInFlightRef.current) return;
4558 desktopOutsidePointerReleaseTimeoutRef.current = window.setTimeout(() => {
4559 if (desktopOutsidePointerInFlightRef.current) {
4560 finishOutsidePointerInteraction();
4561 }
4562 }, 0);
4563 };
4564 const handleClick = () => {
4565 if (desktopOutsidePointerInFlightRef.current) {
4566 finishOutsidePointerInteraction();
4567 }
4568 };
4569
4570 document.addEventListener("focusin", handleFocusIn, true);
4571 document.addEventListener("pointerdown", handlePointerDown, true);
4572 document.addEventListener("pointerup", handlePointerUp, true);
4573 document.addEventListener("pointercancel", handlePointerUp, true);
4574 document.addEventListener("click", handleClick);
4575 return () => {
4576 document.removeEventListener("focusin", handleFocusIn, true);
4577 document.removeEventListener("pointerdown", handlePointerDown, true);
4578 document.removeEventListener("pointerup", handlePointerUp, true);
4579 document.removeEventListener("pointercancel", handlePointerUp, true);
4580 document.removeEventListener("click", handleClick);
4581 if (desktopOutsidePointerReleaseTimeoutRef.current !== null) {
4582 window.clearTimeout(desktopOutsidePointerReleaseTimeoutRef.current);
4583 desktopOutsidePointerReleaseTimeoutRef.current = null;
4584 }
4585 desktopOutsidePointerInFlightRef.current = false;
4586 };
4587 }, [isComposerFocused, isMobileViewport, scheduleComposerCollapseCheck, setIsComposerFocused]);
45884483
4589 useEffect(() => {4484 useEffect(() => {
4590 return () => {4485 return () => {
...@@ -4790,10 +4685,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4790,10 +4685,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
47904685
4791 setIsComposerScrollCollapsed(false);4686 setIsComposerScrollCollapsed(false);
4792 if (isComposerResting && !target.closest('[data-testid="composer-editor"]')) {4687 if (isComposerResting && !target.closest('[data-testid="composer-editor"]')) {
4793 // Clicking resting-surface padding would otherwise blur the still4688 // Clicking resting-surface padding would otherwise blur the editor.
4794 // focused editor after pointerdown: expansion starts, the blur check4689 // Treat that padding like the editor without stealing native caret
4795 // runs, and it immediately collapses again. Treat that padding like4690 // placement from text.
4796 // the editor without stealing native caret placement from text.
4797 event.preventDefault();4691 event.preventDefault();
4798 setIsComposerFocused(true);4692 setIsComposerFocused(true);
4799 scheduleComposerFocus();4693 scheduleComposerFocus();
apps/web/src/components/chat/MessagesTimeline.test.tsx+6-4
...@@ -267,17 +267,17 @@ describe("MessagesTimeline", () => {...@@ -267,17 +267,17 @@ describe("MessagesTimeline", () => {
267 getState: () => ({ isAtEnd: timelineIsAtEnd }),267 getState: () => ({ isAtEnd: timelineIsAtEnd }),
268 getScrollableNode: () => null,268 getScrollableNode: () => null,
269 } as unknown as LegendListRef;269 } as unknown as LegendListRef;
270 let isResting = true;270 let isResting = false;
271 let composerState: ReturnType<typeof useComposerFocusState> | undefined;
271 function ThreadProbe() {272 function ThreadProbe() {
272 const composer = useComposerFocusState(false);273 const composer = useComposerFocusState();
273 useLayoutEffect(() => {274 useLayoutEffect(() => {
275 composerState = composer;
274 isResting = shouldUseRestingComposerLayout({276 isResting = shouldUseRestingComposerLayout({
275 isExistingThread: true,277 isExistingThread: true,
276 isMobileViewport: false,278 isMobileViewport: false,
277 isFocused: composer.isComposerFocused,
278 isScrollCollapsed: composer.isComposerScrollCollapsed,279 isScrollCollapsed: composer.isComposerScrollCollapsed,
279 hasExpandedChrome: false,280 hasExpandedChrome: false,
280 collapseOnBlur: true,
281 timelineOverflows: true,281 timelineOverflows: true,
282 });282 });
283 });283 });
...@@ -309,6 +309,8 @@ describe("MessagesTimeline", () => {...@@ -309,6 +309,8 @@ describe("MessagesTimeline", () => {
309 await act(() => {309 await act(() => {
310 renderer = create(<ThreadProbe />);310 renderer = create(<ThreadProbe />);
311 });311 });
312 // The user scrolled up to read, so the composer is resting.
313 await act(() => composerState!.setIsComposerScrollCollapsed(true));
312 const toggle = renderer!.root.findByProps({ "aria-expanded": false });314 const toggle = renderer!.root.findByProps({ "aria-expanded": false });
313 await act(() => toggle.props.onClick());315 await act(() => toggle.props.onClick());
314 await flushFrame();316 await flushFrame();
apps/web/src/components/chat/composerSelectionHold.test.ts deleted-55
...@@ -1,55 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { selectionHoldsComposerOpen } from "./composerSelectionHold";
4
5function node(parent: Node | null = null) {
6 const self = {
7 parent,
8 contains(other: Node | null): boolean {
9 let cursor: { parent: Node | null } | null = other as unknown as { parent: Node | null };
10 while (cursor) {
11 if (cursor === (self as unknown)) return true;
12 cursor = cursor.parent as { parent: Node | null } | null;
13 }
14 return false;
15 },
16 };
17 return self as unknown as Node;
18}
19
20function selection(anchor: Node, collapsed = false) {
21 return {
22 isCollapsed: collapsed,
23 rangeCount: 1,
24 getRangeAt: () => ({ commonAncestorContainer: anchor }) as Range,
25 };
26}
27
28describe("selectionHoldsComposerOpen", () => {
29 const timeline = node();
30 const message = node(timeline);
31 const elsewhere = node();
32
33 it("holds for a range inside the timeline", () => {
34 expect(selectionHoldsComposerOpen(selection(message), timeline)).toBe(true);
35 });
36
37 it("ignores a caret with nothing selected", () => {
38 expect(selectionHoldsComposerOpen(selection(message, true), timeline)).toBe(false);
39 });
40
41 it("ignores selections outside the timeline", () => {
42 expect(selectionHoldsComposerOpen(selection(elsewhere), timeline)).toBe(false);
43 });
44
45 it("ignores an empty or missing selection", () => {
46 expect(selectionHoldsComposerOpen(null, timeline)).toBe(false);
47 expect(
48 selectionHoldsComposerOpen(
49 { isCollapsed: false, rangeCount: 0, getRangeAt: () => ({}) as Range },
50 timeline,
51 ),
52 ).toBe(false);
53 expect(selectionHoldsComposerOpen(selection(message), null)).toBe(false);
54 });
55});
apps/web/src/components/chat/composerSelectionHold.ts deleted-15
...@@ -1,15 +0,0 @@
1/**
2 * Whether a live text selection inside the timeline should hold the composer
3 * open. A drag-select in the conversation blurs the composer, and letting it
4 * rest mid-gesture reflows the timeline, which dismisses the selection
5 * toolbar the user was about to use.
6 */
7export function selectionHoldsComposerOpen(
8 selection: Pick<Selection, "isCollapsed" | "rangeCount" | "getRangeAt"> | null,
9 timeline: Node | null,
10): boolean {
11 if (!timeline || !selection || selection.isCollapsed || selection.rangeCount === 0) {
12 return false;
13 }
14 return timeline.contains(selection.getRangeAt(0).commonAncestorContainer);
15}
apps/web/src/components/chat/useComposerFocusState.test.tsx+8-15
...@@ -9,17 +9,15 @@ let root: Root;...@@ -9,17 +9,15 @@ let root: Root;
9let composer: ReturnType<typeof useComposerFocusState>;9let composer: ReturnType<typeof useComposerFocusState>;
10let isResting: boolean;10let isResting: boolean;
1111
12function ComposerProbe({ isMobileViewport = false }: { isMobileViewport?: boolean }) {12function ComposerProbe() {
13 const state = useComposerFocusState(isMobileViewport);13 const state = useComposerFocusState();
14 useLayoutEffect(() => {14 useLayoutEffect(() => {
15 composer = state;15 composer = state;
16 isResting = shouldUseRestingComposerLayout({16 isResting = shouldUseRestingComposerLayout({
17 isExistingThread: true,17 isExistingThread: true,
18 isMobileViewport,18 isMobileViewport: false,
19 isFocused: state.isComposerFocused,
20 isScrollCollapsed: state.isComposerScrollCollapsed,19 isScrollCollapsed: state.isComposerScrollCollapsed,
21 hasExpandedChrome: false,20 hasExpandedChrome: false,
22 collapseOnBlur: true,
23 timelineOverflows: true,21 timelineOverflows: true,
24 });22 });
25 });23 });
...@@ -54,19 +52,13 @@ afterEach(async () => {...@@ -54,19 +52,13 @@ afterEach(async () => {
54});52});
5553
56describe("composer focus state", () => {54describe("composer focus state", () => {
57 it("expands at the timeline end after a tool call takes focus", async () => {55 it("stays expanded when the composer loses focus", async () => {
58 await act(() => composer.setIsComposerFocused(true));56 await act(() => composer.setIsComposerFocused(true));
59 expect(isResting).toBe(false);57 expect(isResting).toBe(false);
6058
61 // A tool disclosure takes focus before the user scrolls through its output.59 // A tool disclosure takes focus away from the editor.
62 await act(() => composer.setIsComposerFocused(false));60 await act(() => composer.setIsComposerFocused(false));
63 expect(isResting).toBe(true);
64
65 await act(() => composer.restoreAfterTimelineReachedEnd());
66 expect(isResting).toBe(false);61 expect(isResting).toBe(false);
67
68 await act(() => composer.setIsComposerFocused(false));
69 expect(isResting).toBe(true);
70 });62 });
7163
72 it("can collapse again on the next scroll after returning to the end", async () => {64 it("can collapse again on the next scroll after returning to the end", async () => {
...@@ -80,9 +72,10 @@ describe("composer focus state", () => {...@@ -80,9 +72,10 @@ describe("composer focus state", () => {
80 expect(isResting).toBe(true);72 expect(isResting).toBe(true);
81 });73 });
8274
83 it("does not expand the phone composer when the timeline reaches the end", async () => {75 it("does not move focus into the composer when the timeline reaches the end", async () => {
84 await act(() => root.render(<ComposerProbe isMobileViewport />));76 await act(() => composer.setIsComposerScrollCollapsed(true));
85 await act(() => composer.restoreAfterTimelineReachedEnd());77 await act(() => composer.restoreAfterTimelineReachedEnd());
78 expect(isResting).toBe(false);
86 expect(composer.isComposerFocused).toBe(false);79 expect(composer.isComposerFocused).toBe(false);
87 });80 });
88});81});
apps/web/src/components/chat/useComposerFocusState.ts+4-7
...@@ -1,17 +1,14 @@...@@ -1,17 +1,14 @@
1import { useCallback, useState } from "react";1import { useCallback, useState } from "react";
22
3export function useComposerFocusState(isMobileViewport: boolean) {3export function useComposerFocusState() {
4 const [isComposerFocused, setIsComposerFocused] = useState(false);4 const [isComposerFocused, setIsComposerFocused] = useState(false);
5 const [isComposerScrollCollapsed, setIsComposerScrollCollapsed] = useState(false);5 const [isComposerScrollCollapsed, setIsComposerScrollCollapsed] = useState(false);
66
7 // Reaching the end of the timeline lifts a scroll collapse without moving
8 // DOM focus to the editor.
7 const restoreAfterTimelineReachedEnd = useCallback(() => {9 const restoreAfterTimelineReachedEnd = useCallback(() => {
8 setIsComposerScrollCollapsed(false);10 setIsComposerScrollCollapsed(false);
9 // Restore the expanded layout after a timeline control takes focus too.11 }, []);
10 // This state holds the layout open without moving DOM focus to the editor.
11 if (!isMobileViewport) {
12 setIsComposerFocused(true);
13 }
14 }, [isMobileViewport]);
1512
16 return {13 return {
17 isComposerFocused,14 isComposerFocused,
apps/web/src/components/composerFooterLayout.test.ts+4-34
...@@ -100,47 +100,21 @@ describe("shouldUseRestingComposerLayout", () => {...@@ -100,47 +100,21 @@ describe("shouldUseRestingComposerLayout", () => {
100 const resting = {100 const resting = {
101 isExistingThread: true,101 isExistingThread: true,
102 isMobileViewport: false,102 isMobileViewport: false,
103 isFocused: false,103 isScrollCollapsed: true,
104 isScrollCollapsed: false,
105 hasExpandedChrome: false,104 hasExpandedChrome: false,
106 collapseOnBlur: true,
107 timelineOverflows: true,105 timelineOverflows: true,
108 };106 };
109107
110 it("uses the resting layout for an unfocused desktop composer", () => {108 it("uses the resting layout after a timeline scroll", () => {
111 expect(shouldUseRestingComposerLayout(resting)).toBe(true);109 expect(shouldUseRestingComposerLayout(resting)).toBe(true);
112 });110 });
113111
114 it("keeps an unfocused composer expanded when blur collapse is off", () => {112 it("keeps the composer expanded until the timeline is scrolled", () => {
115 expect(shouldUseRestingComposerLayout({ ...resting, collapseOnBlur: false })).toBe(false);113 expect(shouldUseRestingComposerLayout({ ...resting, isScrollCollapsed: false })).toBe(false);
116 });
117
118 it("rests a scroll-collapsed composer even while focused", () => {
119 expect(
120 shouldUseRestingComposerLayout({ ...resting, isFocused: true, isScrollCollapsed: true }),
121 ).toBe(true);
122 });
123
124 it("rests a scroll-collapsed composer regardless of the blur preference", () => {
125 expect(
126 shouldUseRestingComposerLayout({
127 ...resting,
128 isFocused: true,
129 isScrollCollapsed: true,
130 collapseOnBlur: false,
131 }),
132 ).toBe(true);
133 });114 });
134115
135 it("keeps the composer expanded while the timeline fits above it", () => {116 it("keeps the composer expanded while the timeline fits above it", () => {
136 expect(shouldUseRestingComposerLayout({ ...resting, timelineOverflows: false })).toBe(false);117 expect(shouldUseRestingComposerLayout({ ...resting, timelineOverflows: false })).toBe(false);
137 expect(
138 shouldUseRestingComposerLayout({
139 ...resting,
140 isScrollCollapsed: true,
141 timelineOverflows: false,
142 }),
143 ).toBe(false);
144 });118 });
145119
146 it("keeps new-thread composers expanded", () => {120 it("keeps new-thread composers expanded", () => {
...@@ -151,10 +125,6 @@ describe("shouldUseRestingComposerLayout", () => {...@@ -151,10 +125,6 @@ describe("shouldUseRestingComposerLayout", () => {
151 expect(shouldUseRestingComposerLayout({ ...resting, isMobileViewport: true })).toBe(false);125 expect(shouldUseRestingComposerLayout({ ...resting, isMobileViewport: true })).toBe(false);
152 });126 });
153127
154 it("expands when focus is anywhere in the composer", () => {
155 expect(shouldUseRestingComposerLayout({ ...resting, isFocused: true })).toBe(false);
156 });
157
158 it("keeps drawers and composer-owned menus expanded", () => {128 it("keeps drawers and composer-owned menus expanded", () => {
159 expect(shouldUseRestingComposerLayout({ ...resting, hasExpandedChrome: true })).toBe(false);129 expect(shouldUseRestingComposerLayout({ ...resting, hasExpandedChrome: true })).toBe(false);
160 });130 });
apps/web/src/components/composerFooterLayout.ts+5-8
...@@ -26,10 +26,8 @@ export function shouldUseCompactComposerFooter(...@@ -26,10 +26,8 @@ export function shouldUseCompactComposerFooter(
26export function shouldUseRestingComposerLayout(input: {26export function shouldUseRestingComposerLayout(input: {
27 isExistingThread: boolean;27 isExistingThread: boolean;
28 isMobileViewport: boolean;28 isMobileViewport: boolean;
29 isFocused: boolean;
30 isScrollCollapsed: boolean;29 isScrollCollapsed: boolean;
31 hasExpandedChrome: boolean;30 hasExpandedChrome: boolean;
32 collapseOnBlur: boolean;
33 /** Whether the timeline has more content than fits above the composer. */31 /** Whether the timeline has more content than fits above the composer. */
34 timelineOverflows: boolean;32 timelineOverflows: boolean;
35}): boolean {33}): boolean {
...@@ -42,20 +40,19 @@ export function shouldUseRestingComposerLayout(input: {...@@ -42,20 +40,19 @@ export function shouldUseRestingComposerLayout(input: {
42 // desktop width, and where the strip is missing or too narrow the controls40 // desktop width, and where the strip is missing or too narrow the controls
43 // simply return when the composer is focused.41 // simply return when the composer is focused.
44 //42 //
45 // A scroll collapse rests the composer regardless of the blur preference:43 // Only a timeline scroll rests the composer: the user asked for it with the
46 // the user asked for it with the gesture, and it lifts on the next44 // gesture, and it lifts on the next composer interaction. Losing focus never
47 // composer interaction. With blur collapse off, losing focus alone never45 // rests it, so clicking a message, copying output, or selecting text for a
48 // rests the composer.46 // citation leaves the composer where it was.
49 //47 //
50 // Resting exists to give reading space back to the timeline. A thread that48 // Resting exists to give reading space back to the timeline. A thread that
51 // fits above the composer has nothing to reclaim, so it stays expanded and49 // fits above the composer has nothing to reclaim, so it stays expanded and
52 // never shows the collapsed row that a fresh thread would otherwise open on.50 // never shows the collapsed row that a fresh thread would otherwise open on.
53 const collapsed = input.isScrollCollapsed || (input.collapseOnBlur && !input.isFocused);
54 return (51 return (
55 input.isExistingThread &&52 input.isExistingThread &&
56 !input.isMobileViewport &&53 !input.isMobileViewport &&
57 input.timelineOverflows &&54 input.timelineOverflows &&
58 collapsed &&55 input.isScrollCollapsed &&
59 !input.hasExpandedChrome56 !input.hasExpandedChrome
60 );57 );
61}58}
apps/web/src/components/settings/SettingsPanels.tsx+11-50
...@@ -174,12 +174,6 @@ const TIMESTAMP_FORMAT_LABELS = {...@@ -174,12 +174,6 @@ const TIMESTAMP_FORMAT_LABELS = {
174 "24-hour": "24-hour",174 "24-hour": "24-hour",
175} as const;175} as const;
176176
177const COMPOSER_COLLAPSE_TRIGGER_LABELS = {
178 blur: "On unfocus",
179 scroll: "On scroll",
180} as const;
181type ComposerCollapseTrigger = keyof typeof COMPOSER_COLLAPSE_TRIGGER_LABELS;
182
183const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = {177const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = {
184 stacked: "Stacked",178 stacked: "Stacked",
185 split: "Split",179 split: "Split",
...@@ -548,9 +542,8 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -548,9 +542,8 @@ export function useSettingsRestore(onRestored?: () => void) {
548 ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu542 ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu
549 ? ["Show skills in slash menu"]543 ? ["Show skills in slash menu"]
550 : []),544 : []),
551 ...(settings.composerCollapseOnBlur !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur ||545 ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll
552 settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll546 ? ["Collapse composer on scroll"]
553 ? ["Collapse composer"]
554 : []),547 : []),
555 ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled548 ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled
556 ? ["Context window indicator"]549 ? ["Context window indicator"]
...@@ -609,7 +602,6 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -609,7 +602,6 @@ export function useSettingsRestore(onRestored?: () => void) {
609 settings.confirmThreadArchive,602 settings.confirmThreadArchive,
610 settings.confirmThreadDelete,603 settings.confirmThreadDelete,
611 settings.confirmThreadUnpin,604 settings.confirmThreadUnpin,
612 settings.composerCollapseOnBlur,
613 settings.composerCollapseOnScroll,605 settings.composerCollapseOnScroll,
614 settings.addProjectBaseDirectory,606 settings.addProjectBaseDirectory,
615 settings.defaultThreadEnvMode,607 settings.defaultThreadEnvMode,
...@@ -715,7 +707,6 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -715,7 +707,6 @@ export function useSettingsRestore(onRestored?: () => void) {
715 diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout,707 diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout,
716 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,708 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
717 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,709 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
718 composerCollapseOnBlur: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur,
719 composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,710 composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,
720 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,711 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,
721 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,712 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
...@@ -2028,13 +2019,6 @@ export function GeneralSettingsPanel() {...@@ -2028,13 +2019,6 @@ export function GeneralSettingsPanel() {
2028 const serverProviders = useAtomValue(primaryServerProvidersAtom);2019 const serverProviders = useAtomValue(primaryServerProvidersAtom);
2029 const supportsAutoSettlement =2020 const supportsAutoSettlement =
2030 useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true;2021 useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true;
2031 const composerCollapseTriggers = useMemo<ComposerCollapseTrigger[]>(
2032 () => [
2033 ...(settings.composerCollapseOnBlur ? (["blur"] as const) : []),
2034 ...(settings.composerCollapseOnScroll ? (["scroll"] as const) : []),
2035 ],
2036 [settings.composerCollapseOnBlur, settings.composerCollapseOnScroll],
2037 );
2038 const diagnosticsDescription = formatDiagnosticsDescription({2022 const diagnosticsDescription = formatDiagnosticsDescription({
2039 localTracingEnabled: observability?.localTracingEnabled ?? false,2023 localTracingEnabled: observability?.localTracingEnabled ?? false,
2040 otlpTracesEnabled: observability?.otlpTracesEnabled ?? false,2024 otlpTracesEnabled: observability?.otlpTracesEnabled ?? false,
...@@ -2361,16 +2345,14 @@ export function GeneralSettingsPanel() {...@@ -2361,16 +2345,14 @@ export function GeneralSettingsPanel() {
23612345
2362 <SettingsRow2346 <SettingsRow
2363 {...searchableSetting("composer-collapse")}2347 {...searchableSetting("composer-collapse")}
2364 description="Rest the composer of an existing thread into a single line when it loses focus, when you scroll the conversation, or both. Pick neither to keep it expanded."2348 description="Rest the composer of an existing thread into a single line when you scroll the conversation. Focus the composer or start typing to expand it again."
2365 resetAction={2349 resetAction={
2366 settings.composerCollapseOnBlur !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur ||
2367 settings.composerCollapseOnScroll !==2350 settings.composerCollapseOnScroll !==
2368 DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll ? (2351 DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll ? (
2369 <SettingResetButton2352 <SettingResetButton
2370 label="collapse composer"2353 label="collapse composer on scroll"
2371 onClick={() =>2354 onClick={() =>
2372 updateSettings({2355 updateSettings({
2373 composerCollapseOnBlur: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur,
2374 composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,2356 composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,
2375 })2357 })
2376 }2358 }
...@@ -2378,34 +2360,13 @@ export function GeneralSettingsPanel() {...@@ -2378,34 +2360,13 @@ export function GeneralSettingsPanel() {
2378 ) : null2360 ) : null
2379 }2361 }
2380 control={2362 control={
2381 <Select2363 <Switch
2382 multiple2364 checked={settings.composerCollapseOnScroll}
2383 value={composerCollapseTriggers}2365 onCheckedChange={(checked) =>
2384 onValueChange={(next) =>2366 updateSettings({ composerCollapseOnScroll: Boolean(checked) })
2385 updateSettings({
2386 composerCollapseOnBlur: next.includes("blur"),
2387 composerCollapseOnScroll: next.includes("scroll"),
2388 })
2389 }2367 }
2390 >2368 aria-label="Collapse composer on scroll"
2391 <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Collapse composer">2369 />
2392 <SelectValue>
2393 {composerCollapseTriggers.length === 0
2394 ? "Never"
2395 : composerCollapseTriggers
2396 .map((trigger) => COMPOSER_COLLAPSE_TRIGGER_LABELS[trigger])
2397 .join(", ")}
2398 </SelectValue>
2399 </SelectTrigger>
2400 <SelectPopup align="end" alignItemWithTrigger={false}>
2401 <SelectItem showCheck value="blur">
2402 {COMPOSER_COLLAPSE_TRIGGER_LABELS.blur}
2403 </SelectItem>
2404 <SelectItem showCheck value="scroll">
2405 {COMPOSER_COLLAPSE_TRIGGER_LABELS.scroll}
2406 </SelectItem>
2407 </SelectPopup>
2408 </Select>
2409 }2370 }
2410 />2371 />
24112372
apps/web/src/components/settings/settingsSearch.ts+2-4
...@@ -214,11 +214,9 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -214,11 +214,9 @@ export const SETTINGS_SEARCH_ITEMS = [
214 },214 },
215 {215 {
216 id: "composer-collapse",216 id: "composer-collapse",
217 title: "Collapse composer",217 title: "Collapse composer on scroll",
218 to: "/settings/general",218 to: "/settings/general",
219 searchTerms: [219 searchTerms: ["composer rest resting scroll wheel conversation timeline shrink minimize"],
220 "composer rest resting unfocus blur focus click away scroll wheel conversation timeline shrink minimize",
221 ],
222 },220 },
223 {221 {
224 id: "provider-update-checks",222 id: "provider-update-checks",
apps/web/src/components/ui/select.tsx+1-14
...@@ -4,7 +4,7 @@ import { mergeProps } from "@base-ui/react/merge-props";...@@ -4,7 +4,7 @@ import { mergeProps } from "@base-ui/react/merge-props";
4import { Select as SelectPrimitive } from "@base-ui/react/select";4import { Select as SelectPrimitive } from "@base-ui/react/select";
5import { useRender } from "@base-ui/react/use-render";5import { useRender } from "@base-ui/react/use-render";
6import { cva, type VariantProps } from "class-variance-authority";6import { cva, type VariantProps } from "class-variance-authority";
7import { CheckIcon, ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react";7import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react";
8import type * as React from "react";8import type * as React from "react";
99
10import { cn } from "~/lib/utils";10import { cn } from "~/lib/utils";
...@@ -180,32 +180,19 @@ function SelectItem({...@@ -180,32 +180,19 @@ function SelectItem({
180 className,180 className,
181 children,181 children,
182 hideIndicator: _hideIndicator = false,182 hideIndicator: _hideIndicator = false,
183 showCheck = false,
184 ...props183 ...props
185}: SelectPrimitive.Item.Props & {184}: SelectPrimitive.Item.Props & {
186 hideIndicator?: boolean;185 hideIndicator?: boolean;
187 /** Render a check mark on selected items. Multi-selects use it so every chosen item is visible at once. */
188 showCheck?: boolean;
189}) {186}) {
190 return (187 return (
191 <SelectPrimitive.Item188 <SelectPrimitive.Item
192 className={cn(189 className={cn(
193 "flex min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-pointer items-center rounded-sm px-2 py-1 text-base outline-none data-selected:bg-foreground/[0.08] data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",190 "flex min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-pointer items-center rounded-sm px-2 py-1 text-base outline-none data-selected:bg-foreground/[0.08] data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
194 showCheck && "gap-2",
195 className,191 className,
196 )}192 )}
197 data-slot="select-item"193 data-slot="select-item"
198 {...props}194 {...props}
199 >195 >
200 {showCheck ? (
201 <SelectPrimitive.ItemIndicator
202 className="flex w-4 shrink-0 items-center justify-center"
203 data-slot="select-item-indicator"
204 keepMounted
205 >
206 <CheckIcon className="in-data-[selected]:opacity-100 opacity-0" />
207 </SelectPrimitive.ItemIndicator>
208 ) : null}
209 <SelectPrimitive.ItemText196 <SelectPrimitive.ItemText
210 className="min-w-0 flex-1 [&_svg:not([class*='text-'])]:text-muted-foreground"197 className="min-w-0 flex-1 [&_svg:not([class*='text-'])]:text-muted-foreground"
211 data-slot="select-item-text"198 data-slot="select-item-text"
packages/contracts/src/settings.test.ts+8-9
...@@ -329,19 +329,18 @@ describe("ClientSettings context window meter", () => {...@@ -329,19 +329,18 @@ describe("ClientSettings context window meter", () => {
329});329});
330330
331describe("ClientSettings composer collapse", () => {331describe("ClientSettings composer collapse", () => {
332 it("collapses on blur and scroll by default and accepts opting out of each", () => {332 it("collapses on scroll by default and accepts opting out", () => {
333 const defaults = decodeClientSettings({});333 expect(decodeClientSettings({}).composerCollapseOnScroll).toBe(true);
334 expect(defaults.composerCollapseOnBlur).toBe(true);
335 expect(defaults.composerCollapseOnScroll).toBe(true);
336
337 const blurOff = decodeClientSettings({ composerCollapseOnBlur: false });
338 expect(blurOff.composerCollapseOnBlur).toBe(false);
339 expect(blurOff.composerCollapseOnScroll).toBe(true);
340
341 expect(334 expect(
342 decodeClientSettingsPatch({ composerCollapseOnScroll: false }).composerCollapseOnScroll,335 decodeClientSettingsPatch({ composerCollapseOnScroll: false }).composerCollapseOnScroll,
343 ).toBe(false);336 ).toBe(false);
344 });337 });
338
339 it("drops the retired blur trigger key", () => {
340 const decoded = decodeClientSettings({ composerCollapseOnBlur: false });
341 expect(decoded.composerCollapseOnScroll).toBe(true);
342 expect(decoded).not.toHaveProperty("composerCollapseOnBlur");
343 });
345});344});
346345
347describe("ServerSettings thread settlement", () => {346describe("ServerSettings thread settlement", () => {
packages/contracts/src/settings.ts+2-4
...@@ -337,9 +337,8 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -337,9 +337,8 @@ export const ClientSettingsSchema = Schema.Struct({
337 // Legacy context window meter. The composer hides it by default; users who337 // Legacy context window meter. The composer hides it by default; users who
338 // still want the old usage indicator can restore it from Settings.338 // still want the old usage indicator can restore it from Settings.
339 contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),339 contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
340 // Desktop resting composer. Each trigger that settles an existing thread's340 // Desktop resting composer: scrolling an existing thread's conversation
341 // composer into its single-line layout can be turned off on its own.341 // settles the composer into its single-line layout. Losing focus never does.
342 composerCollapseOnBlur: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
343 composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),342 composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
344 proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),343 proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
345 showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),344 showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
...@@ -1270,7 +1269,6 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -1270,7 +1269,6 @@ export const ClientSettingsPatch = Schema.Struct({
1270 ),1269 ),
1271 planModeEnabled: Schema.optionalKey(Schema.Boolean),1270 planModeEnabled: Schema.optionalKey(Schema.Boolean),
1272 contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),1271 contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),
1273 composerCollapseOnBlur: Schema.optionalKey(Schema.Boolean),
1274 composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean),1272 composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean),
1275 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),1273 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),
1276 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),1274 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),