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 = {
2727 confirmThreadDelete: false,
2828 confirmThreadUnpin: false,
2929 contextWindowMeterEnabled: false,
30 composerCollapseOnBlur: false,
3130 composerCollapseOnScroll: true,
3231 dismissedProviderUpdateNotificationKeys: [],
3332 diffIgnoreWhitespace: true,
apps/web/src/components/chat/ChatComposer.tsx+12-118
......@@ -213,7 +213,6 @@ import {
213213 resetComposerScrollGesture,
214214 suppressActiveComposerScrollGesture,
215215} from "./composerScrollGesture";
216import { selectionHoldsComposerOpen } from "./composerSelectionHold";
217216import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
218217
219218function ComposerVideoThumbnail({ file }: { file: File }) {
......@@ -1813,7 +1812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18131812 isComposerScrollCollapsed,
18141813 setIsComposerScrollCollapsed,
18151814 restoreAfterTimelineReachedEnd,
1816 } = useComposerFocusState(isMobileViewport);
1815 } = useComposerFocusState();
18171816 const [composerSubmissionError, setComposerSubmissionError] = useState<string | null>(null);
18181817 const [providerInputSubmissionError, setProviderInputSubmissionError] = useState<string | null>(
18191818 null,
......@@ -1847,8 +1846,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18471846 const mobileComposerExpandFrameRef = useRef<number | null>(null);
18481847 const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);
18491848 const mobileComposerExpandInFlightRef = useRef(false);
1850 const desktopOutsidePointerInFlightRef = useRef(false);
1851 const desktopOutsidePointerReleaseTimeoutRef = useRef<number | null>(null);
18521849 const composerScrollCollapseTimeoutRef = useRef<number | null>(null);
18531850 const composerScrollCollapseEligibleRef = useRef(false);
18541851 const windowRefocusInFlightRef = useRef(false);
......@@ -3716,10 +3713,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
37163713 const isComposerResting = shouldUseRestingComposerLayout({
37173714 isExistingThread: routeKind === "server" && activeThreadId !== null,
37183715 isMobileViewport,
3719 isFocused: isComposerFocused,
37203716 isScrollCollapsed: isComposerScrollCollapsed,
37213717 hasExpandedChrome: composerHasExpandedChrome,
3722 collapseOnBlur: settings.composerCollapseOnBlur,
37233718 timelineOverflows,
37243719 });
37253720 // The relocated controls live in the context strip whenever the composer is
......@@ -3804,9 +3799,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
38043799 settings.composerCollapseOnScroll &&
38053800 !composerHasExpandedChrome &&
38063801 !showInlineTasksBadge;
3807 // Scrolling only has something to collapse while the composer is expanded.
3808 // With blur collapse off that includes an unfocused composer, so the wheel
3809 // handler keys off this rather than editor focus.
3802 // Scrolling only has something to collapse while the composer is expanded,
3803 // focused or not, so the wheel handler keys off the resting state rather
3804 // than editor focus.
38103805 composerScrollCollapseEligibleRef.current = canScrollCollapseComposer && !isComposerResting;
38113806
38123807 useEffect(() => {
......@@ -4455,8 +4450,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
44554450 const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => {
44564451 void onImplementPlanInNewThread();
44574452 }, [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.
44584455 const scheduleComposerCollapseCheck = useCallback(() => {
4459 if (isMobileViewport && mobileComposerExpandInFlightRef.current) {
4456 if (!isMobileViewport || mobileComposerExpandInFlightRef.current) {
44604457 return;
44614458 }
44624459 if (composerBlurFrameRef.current !== null) {
......@@ -4464,10 +4461,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
44644461 }
44654462 composerBlurFrameRef.current = window.requestAnimationFrame(() => {
44664463 composerBlurFrameRef.current = null;
4467 if (isMobileViewport && mobileComposerExpandInFlightRef.current) {
4468 return;
4469 }
4470 if (!isMobileViewport && desktopOutsidePointerInFlightRef.current) {
4464 if (mobileComposerExpandInFlightRef.current) {
44714465 return;
44724466 }
44734467 const composerSurface = composerSurfaceRef.current;
......@@ -4483,108 +4477,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
44834477 ) {
44844478 return;
44854479 }
4486 if (
4487 !isMobileViewport &&
4488 selectionHoldsComposerOpen(window.getSelection(), getTimelineScrollableNode())
4489 ) {
4490 // The check runs again once the selection clears.
4491 return;
4492 }
44934480 setIsComposerFocused(false);
44944481 });
4495 }, [getTimelineScrollableNode, 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]);
4482 }, [isMobileViewport, setIsComposerFocused]);
45884483
45894484 useEffect(() => {
45904485 return () => {
......@@ -4790,10 +4685,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
47904685
47914686 setIsComposerScrollCollapsed(false);
47924687 if (isComposerResting && !target.closest('[data-testid="composer-editor"]')) {
4793 // Clicking resting-surface padding would otherwise blur the still
4794 // focused editor after pointerdown: expansion starts, the blur check
4795 // runs, and it immediately collapses again. Treat that padding like
4796 // the editor without stealing native caret placement from text.
4688 // Clicking resting-surface padding would otherwise blur the editor.
4689 // Treat that padding like the editor without stealing native caret
4690 // placement from text.
47974691 event.preventDefault();
47984692 setIsComposerFocused(true);
47994693 scheduleComposerFocus();
apps/web/src/components/chat/MessagesTimeline.test.tsx+6-4
......@@ -267,17 +267,17 @@ describe("MessagesTimeline", () => {
267267 getState: () => ({ isAtEnd: timelineIsAtEnd }),
268268 getScrollableNode: () => null,
269269 } as unknown as LegendListRef;
270 let isResting = true;
270 let isResting = false;
271 let composerState: ReturnType<typeof useComposerFocusState> | undefined;
271272 function ThreadProbe() {
272 const composer = useComposerFocusState(false);
273 const composer = useComposerFocusState();
273274 useLayoutEffect(() => {
275 composerState = composer;
274276 isResting = shouldUseRestingComposerLayout({
275277 isExistingThread: true,
276278 isMobileViewport: false,
277 isFocused: composer.isComposerFocused,
278279 isScrollCollapsed: composer.isComposerScrollCollapsed,
279280 hasExpandedChrome: false,
280 collapseOnBlur: true,
281281 timelineOverflows: true,
282282 });
283283 });
......@@ -309,6 +309,8 @@ describe("MessagesTimeline", () => {
309309 await act(() => {
310310 renderer = create(<ThreadProbe />);
311311 });
312 // The user scrolled up to read, so the composer is resting.
313 await act(() => composerState!.setIsComposerScrollCollapsed(true));
312314 const toggle = renderer!.root.findByProps({ "aria-expanded": false });
313315 await act(() => toggle.props.onClick());
314316 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;
99let composer: ReturnType<typeof useComposerFocusState>;
1010let isResting: boolean;
1111
12function ComposerProbe({ isMobileViewport = false }: { isMobileViewport?: boolean }) {
13 const state = useComposerFocusState(isMobileViewport);
12function ComposerProbe() {
13 const state = useComposerFocusState();
1414 useLayoutEffect(() => {
1515 composer = state;
1616 isResting = shouldUseRestingComposerLayout({
1717 isExistingThread: true,
18 isMobileViewport,
19 isFocused: state.isComposerFocused,
18 isMobileViewport: false,
2019 isScrollCollapsed: state.isComposerScrollCollapsed,
2120 hasExpandedChrome: false,
22 collapseOnBlur: true,
2321 timelineOverflows: true,
2422 });
2523 });
......@@ -54,19 +52,13 @@ afterEach(async () => {
5452});
5553
5654describe("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 () => {
5856 await act(() => composer.setIsComposerFocused(true));
5957 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.
6260 await act(() => composer.setIsComposerFocused(false));
63 expect(isResting).toBe(true);
64
65 await act(() => composer.restoreAfterTimelineReachedEnd());
6661 expect(isResting).toBe(false);
67
68 await act(() => composer.setIsComposerFocused(false));
69 expect(isResting).toBe(true);
7062 });
7163
7264 it("can collapse again on the next scroll after returning to the end", async () => {
......@@ -80,9 +72,10 @@ describe("composer focus state", () => {
8072 expect(isResting).toBe(true);
8173 });
8274
83 it("does not expand the phone composer when the timeline reaches the end", async () => {
84 await act(() => root.render(<ComposerProbe isMobileViewport />));
75 it("does not move focus into the composer when the timeline reaches the end", async () => {
76 await act(() => composer.setIsComposerScrollCollapsed(true));
8577 await act(() => composer.restoreAfterTimelineReachedEnd());
78 expect(isResting).toBe(false);
8679 expect(composer.isComposerFocused).toBe(false);
8780 });
8881});
apps/web/src/components/chat/useComposerFocusState.ts+4-7
......@@ -1,17 +1,14 @@
11import { useCallback, useState } from "react";
22
3export function useComposerFocusState(isMobileViewport: boolean) {
3export function useComposerFocusState() {
44 const [isComposerFocused, setIsComposerFocused] = useState(false);
55 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.
79 const restoreAfterTimelineReachedEnd = useCallback(() => {
810 setIsComposerScrollCollapsed(false);
9 // Restore the expanded layout after a timeline control takes focus too.
10 // This state holds the layout open without moving DOM focus to the editor.
11 if (!isMobileViewport) {
12 setIsComposerFocused(true);
13 }
14 }, [isMobileViewport]);
11 }, []);
1512
1613 return {
1714 isComposerFocused,
apps/web/src/components/composerFooterLayout.test.ts+4-34
......@@ -100,47 +100,21 @@ describe("shouldUseRestingComposerLayout", () => {
100100 const resting = {
101101 isExistingThread: true,
102102 isMobileViewport: false,
103 isFocused: false,
104 isScrollCollapsed: false,
103 isScrollCollapsed: true,
105104 hasExpandedChrome: false,
106 collapseOnBlur: true,
107105 timelineOverflows: true,
108106 };
109107
110 it("uses the resting layout for an unfocused desktop composer", () => {
108 it("uses the resting layout after a timeline scroll", () => {
111109 expect(shouldUseRestingComposerLayout(resting)).toBe(true);
112110 });
113111
114 it("keeps an unfocused composer expanded when blur collapse is off", () => {
115 expect(shouldUseRestingComposerLayout({ ...resting, collapseOnBlur: 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);
112 it("keeps the composer expanded until the timeline is scrolled", () => {
113 expect(shouldUseRestingComposerLayout({ ...resting, isScrollCollapsed: false })).toBe(false);
133114 });
134115
135116 it("keeps the composer expanded while the timeline fits above it", () => {
136117 expect(shouldUseRestingComposerLayout({ ...resting, timelineOverflows: false })).toBe(false);
137 expect(
138 shouldUseRestingComposerLayout({
139 ...resting,
140 isScrollCollapsed: true,
141 timelineOverflows: false,
142 }),
143 ).toBe(false);
144118 });
145119
146120 it("keeps new-thread composers expanded", () => {
......@@ -151,10 +125,6 @@ describe("shouldUseRestingComposerLayout", () => {
151125 expect(shouldUseRestingComposerLayout({ ...resting, isMobileViewport: true })).toBe(false);
152126 });
153127
154 it("expands when focus is anywhere in the composer", () => {
155 expect(shouldUseRestingComposerLayout({ ...resting, isFocused: true })).toBe(false);
156 });
157
158128 it("keeps drawers and composer-owned menus expanded", () => {
159129 expect(shouldUseRestingComposerLayout({ ...resting, hasExpandedChrome: true })).toBe(false);
160130 });
apps/web/src/components/composerFooterLayout.ts+5-8
......@@ -26,10 +26,8 @@ export function shouldUseCompactComposerFooter(
2626export function shouldUseRestingComposerLayout(input: {
2727 isExistingThread: boolean;
2828 isMobileViewport: boolean;
29 isFocused: boolean;
3029 isScrollCollapsed: boolean;
3130 hasExpandedChrome: boolean;
32 collapseOnBlur: boolean;
3331 /** Whether the timeline has more content than fits above the composer. */
3432 timelineOverflows: boolean;
3533}): boolean {
......@@ -42,20 +40,19 @@ export function shouldUseRestingComposerLayout(input: {
4240 // desktop width, and where the strip is missing or too narrow the controls
4341 // simply return when the composer is focused.
4442 //
45 // A scroll collapse rests the composer regardless of the blur preference:
46 // the user asked for it with the gesture, and it lifts on the next
47 // composer interaction. With blur collapse off, losing focus alone never
48 // rests the composer.
43 // Only a timeline scroll rests the composer: the user asked for it with the
44 // gesture, and it lifts on the next composer interaction. Losing focus never
45 // rests it, so clicking a message, copying output, or selecting text for a
46 // citation leaves the composer where it was.
4947 //
5048 // Resting exists to give reading space back to the timeline. A thread that
5149 // fits above the composer has nothing to reclaim, so it stays expanded and
5250 // never shows the collapsed row that a fresh thread would otherwise open on.
53 const collapsed = input.isScrollCollapsed || (input.collapseOnBlur && !input.isFocused);
5451 return (
5552 input.isExistingThread &&
5653 !input.isMobileViewport &&
5754 input.timelineOverflows &&
58 collapsed &&
55 input.isScrollCollapsed &&
5956 !input.hasExpandedChrome
6057 );
6158}
apps/web/src/components/settings/SettingsPanels.tsx+11-50
......@@ -174,12 +174,6 @@ const TIMESTAMP_FORMAT_LABELS = {
174174 "24-hour": "24-hour",
175175} 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
183177const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = {
184178 stacked: "Stacked",
185179 split: "Split",
......@@ -548,9 +542,8 @@ export function useSettingsRestore(onRestored?: () => void) {
548542 ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu
549543 ? ["Show skills in slash menu"]
550544 : []),
551 ...(settings.composerCollapseOnBlur !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur ||
552 settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll
553 ? ["Collapse composer"]
545 ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll
546 ? ["Collapse composer on scroll"]
554547 : []),
555548 ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled
556549 ? ["Context window indicator"]
......@@ -609,7 +602,6 @@ export function useSettingsRestore(onRestored?: () => void) {
609602 settings.confirmThreadArchive,
610603 settings.confirmThreadDelete,
611604 settings.confirmThreadUnpin,
612 settings.composerCollapseOnBlur,
613605 settings.composerCollapseOnScroll,
614606 settings.addProjectBaseDirectory,
615607 settings.defaultThreadEnvMode,
......@@ -715,7 +707,6 @@ export function useSettingsRestore(onRestored?: () => void) {
715707 diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout,
716708 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
717709 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
718 composerCollapseOnBlur: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur,
719710 composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,
720711 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,
721712 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
......@@ -2028,13 +2019,6 @@ export function GeneralSettingsPanel() {
20282019 const serverProviders = useAtomValue(primaryServerProvidersAtom);
20292020 const supportsAutoSettlement =
20302021 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 );
20382022 const diagnosticsDescription = formatDiagnosticsDescription({
20392023 localTracingEnabled: observability?.localTracingEnabled ?? false,
20402024 otlpTracesEnabled: observability?.otlpTracesEnabled ?? false,
......@@ -2361,16 +2345,14 @@ export function GeneralSettingsPanel() {
23612345
23622346 <SettingsRow
23632347 {...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."
23652349 resetAction={
2366 settings.composerCollapseOnBlur !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur ||
23672350 settings.composerCollapseOnScroll !==
2368 DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll ? (
2351 DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll ? (
23692352 <SettingResetButton
2370 label="collapse composer"
2353 label="collapse composer on scroll"
23712354 onClick={() =>
23722355 updateSettings({
2373 composerCollapseOnBlur: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur,
23742356 composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,
23752357 })
23762358 }
......@@ -2378,34 +2360,13 @@ export function GeneralSettingsPanel() {
23782360 ) : null
23792361 }
23802362 control={
2381 <Select
2382 multiple
2383 value={composerCollapseTriggers}
2384 onValueChange={(next) =>
2385 updateSettings({
2386 composerCollapseOnBlur: next.includes("blur"),
2387 composerCollapseOnScroll: next.includes("scroll"),
2388 })
2363 <Switch
2364 checked={settings.composerCollapseOnScroll}
2365 onCheckedChange={(checked) =>
2366 updateSettings({ composerCollapseOnScroll: Boolean(checked) })
23892367 }
2390 >
2391 <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Collapse composer">
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>
2368 aria-label="Collapse composer on scroll"
2369 />
24092370 }
24102371 />
24112372
apps/web/src/components/settings/settingsSearch.ts+2-4
......@@ -214,11 +214,9 @@ export const SETTINGS_SEARCH_ITEMS = [
214214 },
215215 {
216216 id: "composer-collapse",
217 title: "Collapse composer",
217 title: "Collapse composer on scroll",
218218 to: "/settings/general",
219 searchTerms: [
220 "composer rest resting unfocus blur focus click away scroll wheel conversation timeline shrink minimize",
221 ],
219 searchTerms: ["composer rest resting scroll wheel conversation timeline shrink minimize"],
222220 },
223221 {
224222 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";
44import { Select as SelectPrimitive } from "@base-ui/react/select";
55import { useRender } from "@base-ui/react/use-render";
66import { cva, type VariantProps } from "class-variance-authority";
7import { CheckIcon, ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react";
7import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react";
88import type * as React from "react";
99
1010import { cn } from "~/lib/utils";
......@@ -180,32 +180,19 @@ function SelectItem({
180180 className,
181181 children,
182182 hideIndicator: _hideIndicator = false,
183 showCheck = false,
184183 ...props
185184}: SelectPrimitive.Item.Props & {
186185 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;
189186}) {
190187 return (
191188 <SelectPrimitive.Item
192189 className={cn(
193190 "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",
195191 className,
196192 )}
197193 data-slot="select-item"
198194 {...props}
199195 >
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}
209196 <SelectPrimitive.ItemText
210197 className="min-w-0 flex-1 [&_svg:not([class*='text-'])]:text-muted-foreground"
211198 data-slot="select-item-text"
packages/contracts/src/settings.test.ts+8-9
......@@ -329,19 +329,18 @@ describe("ClientSettings context window meter", () => {
329329});
330330
331331describe("ClientSettings composer collapse", () => {
332 it("collapses on blur and scroll by default and accepts opting out of each", () => {
333 const defaults = decodeClientSettings({});
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
332 it("collapses on scroll by default and accepts opting out", () => {
333 expect(decodeClientSettings({}).composerCollapseOnScroll).toBe(true);
341334 expect(
342335 decodeClientSettingsPatch({ composerCollapseOnScroll: false }).composerCollapseOnScroll,
343336 ).toBe(false);
344337 });
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 });
345344});
346345
347346describe("ServerSettings thread settlement", () => {
packages/contracts/src/settings.ts+2-4
......@@ -337,9 +337,8 @@ export const ClientSettingsSchema = Schema.Struct({
337337 // Legacy context window meter. The composer hides it by default; users who
338338 // still want the old usage indicator can restore it from Settings.
339339 contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
340 // Desktop resting composer. Each trigger that settles an existing thread's
341 // composer into its single-line layout can be turned off on its own.
342 composerCollapseOnBlur: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
340 // Desktop resting composer: scrolling an existing thread's conversation
341 // settles the composer into its single-line layout. Losing focus never does.
343342 composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
344343 proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
345344 showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
......@@ -1270,7 +1269,6 @@ export const ClientSettingsPatch = Schema.Struct({
12701269 ),
12711270 planModeEnabled: Schema.optionalKey(Schema.Boolean),
12721271 contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),
1273 composerCollapseOnBlur: Schema.optionalKey(Schema.Boolean),
12741272 composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean),
12751273 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),
12761274 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),