authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-03 14:28:36-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 14:28:36-07:00
log03728361aa7beb9c13da320097450e6fe65aac3e
tree217ec6a5888b3ff2789b137bec2e99d7b0e70ca1
parent493fbb58870c912b9cb2ba6c2f1dae938877a59a
signature Signed by PGP key B5690EEEBB952194

feat(web): let users turn off composer collapse on blur and scroll (#9469)

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

10 files changed, 163 insertions(+), 18 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+2
......@@ -28,6 +28,8 @@ const clientSettings: ClientSettings = {
2828 confirmThreadUnpin: false,
2929 continueThreadsAfterServerUpdate: true,
3030 contextWindowMeterEnabled: false,
31 composerCollapseOnBlur: false,
32 composerCollapseOnScroll: true,
3133 dismissedProviderUpdateNotificationKeys: [],
3234 diffIgnoreWhitespace: true,
3335 diffLayout: "stacked",
apps/web/src/components/chat/ChatComposer.tsx+12-10
......@@ -3542,8 +3542,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
35423542 const isComposerResting = shouldUseRestingComposerLayout({
35433543 isExistingThread: routeKind === "server" && activeThreadId !== null,
35443544 isMobileViewport,
3545 isFocused: isComposerFocused && !isComposerScrollCollapsed,
3545 isFocused: isComposerFocused,
3546 isScrollCollapsed: isComposerScrollCollapsed,
35463547 hasExpandedChrome: composerHasExpandedChrome,
3548 collapseOnBlur: settings.composerCollapseOnBlur,
35473549 });
35483550 // The relocated controls live in the context strip whenever the composer is
35493551 // collapsed for any reason, the desktop resting layout or the phone
......@@ -3615,8 +3617,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
36153617 const canTrackComposerScrollGesture =
36163618 routeKind === "server" && activeThreadId !== null && !isMobileViewport;
36173619 const canScrollCollapseComposer =
3618 canTrackComposerScrollGesture && !composerHasExpandedChrome && !showInlineTasksBadge;
3619 composerScrollCollapseEligibleRef.current = canScrollCollapseComposer;
3620 canTrackComposerScrollGesture &&
3621 settings.composerCollapseOnScroll &&
3622 !composerHasExpandedChrome &&
3623 !showInlineTasksBadge;
3624 // Scrolling only has something to collapse while the composer is expanded.
3625 // With blur collapse off that includes an unfocused composer, so the wheel
3626 // handler keys off this rather than editor focus.
3627 composerScrollCollapseEligibleRef.current = canScrollCollapseComposer && !isComposerResting;
36203628
36213629 useEffect(() => {
36223630 if (!canScrollCollapseComposer) {
......@@ -3657,11 +3665,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
36573665 resetComposerScrollGesture(composerScrollGestureRef.current);
36583666 };
36593667 const handleTimelineWheel = (event: WheelEvent) => {
3660 const activeElement = document.activeElement;
3661 const isPromptEditorFocused =
3662 activeElement instanceof HTMLElement &&
3663 activeElement.isContentEditable &&
3664 composerFormRef.current?.contains(activeElement) === true;
36653668 if (event.ctrlKey || !(event.target instanceof Element)) {
36663669 return;
36673670 }
......@@ -3695,8 +3698,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
36953698 now: window.performance.now(),
36963699 deltaPx,
36973700 collapseThresholdPx: COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX,
3698 collapseEligible:
3699 targetsTimeline && composerScrollCollapseEligibleRef.current && isPromptEditorFocused,
3701 collapseEligible: targetsTimeline && composerScrollCollapseEligibleRef.current,
37003702 canScrollInGestureDirection,
37013703 scrollsTowardLogicalEnd: event.deltaY > 0 && isTimelineAtLogicalEnd(),
37023704 });
apps/web/src/components/composerFooterLayout.test.ts+23
......@@ -79,13 +79,36 @@ describe("shouldUseRestingComposerLayout", () => {
7979 isExistingThread: true,
8080 isMobileViewport: false,
8181 isFocused: false,
82 isScrollCollapsed: false,
8283 hasExpandedChrome: false,
84 collapseOnBlur: true,
8385 };
8486
8587 it("uses the resting layout for an unfocused desktop composer", () => {
8688 expect(shouldUseRestingComposerLayout(resting)).toBe(true);
8789 });
8890
91 it("keeps an unfocused composer expanded when blur collapse is off", () => {
92 expect(shouldUseRestingComposerLayout({ ...resting, collapseOnBlur: false })).toBe(false);
93 });
94
95 it("rests a scroll-collapsed composer even while focused", () => {
96 expect(
97 shouldUseRestingComposerLayout({ ...resting, isFocused: true, isScrollCollapsed: true }),
98 ).toBe(true);
99 });
100
101 it("rests a scroll-collapsed composer regardless of the blur preference", () => {
102 expect(
103 shouldUseRestingComposerLayout({
104 ...resting,
105 isFocused: true,
106 isScrollCollapsed: true,
107 collapseOnBlur: false,
108 }),
109 ).toBe(true);
110 });
111
89112 it("keeps new-thread composers expanded", () => {
90113 expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false);
91114 });
apps/web/src/components/composerFooterLayout.ts+9-6
......@@ -27,7 +27,9 @@ export function shouldUseRestingComposerLayout(input: {
2727 isExistingThread: boolean;
2828 isMobileViewport: boolean;
2929 isFocused: boolean;
30 isScrollCollapsed: boolean;
3031 hasExpandedChrome: boolean;
32 collapseOnBlur: boolean;
3133}): boolean {
3234 // Passive draft content is deliberately absent here. Resting only clamps
3335 // the prompt row and overlays its actions; non-image attachment and context
......@@ -37,12 +39,13 @@ export function shouldUseRestingComposerLayout(input: {
3739 // deliberately absent here: resting reclaims vertical space at every
3840 // desktop width, and where the strip is missing or too narrow the controls
3941 // simply return when the composer is focused.
40 return (
41 input.isExistingThread &&
42 !input.isMobileViewport &&
43 !input.isFocused &&
44 !input.hasExpandedChrome
45 );
42 //
43 // A scroll collapse rests the composer regardless of the blur preference:
44 // the user asked for it with the gesture, and it lifts on the next
45 // composer interaction. With blur collapse off, losing focus alone never
46 // rests the composer.
47 const collapsed = input.isScrollCollapsed || (input.collapseOnBlur && !input.isFocused);
48 return input.isExistingThread && !input.isMobileViewport && collapsed && !input.hasExpandedChrome;
4649}
4750
4851export function shouldAnimateComposerRestingTransition(input: {
apps/web/src/components/settings/SettingsPanels.tsx+71
......@@ -172,6 +172,12 @@ const TIMESTAMP_FORMAT_LABELS = {
172172 "24-hour": "24-hour",
173173} as const;
174174
175const COMPOSER_COLLAPSE_TRIGGER_LABELS = {
176 blur: "On unfocus",
177 scroll: "On scroll",
178} as const;
179type ComposerCollapseTrigger = keyof typeof COMPOSER_COLLAPSE_TRIGGER_LABELS;
180
175181const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = {
176182 stacked: "Stacked",
177183 split: "Split",
......@@ -542,6 +548,10 @@ export function useSettingsRestore(onRestored?: () => void) {
542548 ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu
543549 ? ["Show skills in slash menu"]
544550 : []),
551 ...(settings.composerCollapseOnBlur !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur ||
552 settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll
553 ? ["Collapse composer"]
554 : []),
545555 ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled
546556 ? ["Context window indicator"]
547557 : []),
......@@ -599,6 +609,8 @@ export function useSettingsRestore(onRestored?: () => void) {
599609 settings.confirmThreadArchive,
600610 settings.confirmThreadDelete,
601611 settings.confirmThreadUnpin,
612 settings.composerCollapseOnBlur,
613 settings.composerCollapseOnScroll,
602614 settings.addProjectBaseDirectory,
603615 settings.defaultThreadEnvMode,
604616 settings.newWorktreesStartFromOrigin,
......@@ -703,6 +715,8 @@ export function useSettingsRestore(onRestored?: () => void) {
703715 diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout,
704716 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
705717 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
718 composerCollapseOnBlur: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur,
719 composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,
706720 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,
707721 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
708722 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
......@@ -2010,6 +2024,13 @@ export function GeneralSettingsPanel() {
20102024 const serverProviders = useAtomValue(primaryServerProvidersAtom);
20112025 const supportsAutoSettlement =
20122026 useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true;
2027 const composerCollapseTriggers = useMemo<ComposerCollapseTrigger[]>(
2028 () => [
2029 ...(settings.composerCollapseOnBlur ? (["blur"] as const) : []),
2030 ...(settings.composerCollapseOnScroll ? (["scroll"] as const) : []),
2031 ],
2032 [settings.composerCollapseOnBlur, settings.composerCollapseOnScroll],
2033 );
20132034 const diagnosticsDescription = formatDiagnosticsDescription({
20142035 localTracingEnabled: observability?.localTracingEnabled ?? false,
20152036 otlpTracesEnabled: observability?.otlpTracesEnabled ?? false,
......@@ -2334,6 +2355,56 @@ export function GeneralSettingsPanel() {
23342355 }
23352356 />
23362357
2358 <SettingsRow
2359 {...searchableSetting("composer-collapse")}
2360 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."
2361 resetAction={
2362 settings.composerCollapseOnBlur !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur ||
2363 settings.composerCollapseOnScroll !==
2364 DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll ? (
2365 <SettingResetButton
2366 label="collapse composer"
2367 onClick={() =>
2368 updateSettings({
2369 composerCollapseOnBlur: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur,
2370 composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,
2371 })
2372 }
2373 />
2374 ) : null
2375 }
2376 control={
2377 <Select
2378 multiple
2379 value={composerCollapseTriggers}
2380 onValueChange={(next) =>
2381 updateSettings({
2382 composerCollapseOnBlur: next.includes("blur"),
2383 composerCollapseOnScroll: next.includes("scroll"),
2384 })
2385 }
2386 >
2387 <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Collapse composer">
2388 <SelectValue>
2389 {composerCollapseTriggers.length === 0
2390 ? "Never"
2391 : composerCollapseTriggers
2392 .map((trigger) => COMPOSER_COLLAPSE_TRIGGER_LABELS[trigger])
2393 .join(", ")}
2394 </SelectValue>
2395 </SelectTrigger>
2396 <SelectPopup align="end" alignItemWithTrigger={false}>
2397 <SelectItem showCheck value="blur">
2398 {COMPOSER_COLLAPSE_TRIGGER_LABELS.blur}
2399 </SelectItem>
2400 <SelectItem showCheck value="scroll">
2401 {COMPOSER_COLLAPSE_TRIGGER_LABELS.scroll}
2402 </SelectItem>
2403 </SelectPopup>
2404 </Select>
2405 }
2406 />
2407
23372408 <SettingsRow
23382409 serverScoped
23392410 {...searchableSetting("provider-update-checks")}
apps/web/src/components/settings/settingsSearch.ts+8
......@@ -202,6 +202,14 @@ export const SETTINGS_SEARCH_ITEMS = [
202202 to: "/settings/general",
203203 searchTerms: ["command menu dollar $ slash /"],
204204 },
205 {
206 id: "composer-collapse",
207 title: "Collapse composer",
208 to: "/settings/general",
209 searchTerms: [
210 "composer rest resting unfocus blur focus click away scroll wheel conversation timeline shrink minimize",
211 ],
212 },
205213 {
206214 id: "provider-update-checks",
207215 title: "Provider update checks",
apps/web/src/components/ui/select.tsx+14-1
......@@ -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 { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react";
7import { CheckIcon, ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react";
88import type * as React from "react";
99
1010import { cn } from "~/lib/utils";
......@@ -180,19 +180,32 @@ function SelectItem({
180180 className,
181181 children,
182182 hideIndicator: _hideIndicator = false,
183 showCheck = false,
183184 ...props
184185}: SelectPrimitive.Item.Props & {
185186 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;
186189}) {
187190 return (
188191 <SelectPrimitive.Item
189192 className={cn(
190193 "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",
191195 className,
192196 )}
193197 data-slot="select-item"
194198 {...props}
195199 >
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}
196209 <SelectPrimitive.ItemText
197210 className="min-w-0 flex-1 [&_svg:not([class*='text-'])]:text-muted-foreground"
198211 data-slot="select-item-text"
docs/user/composer.md+2-1
......@@ -56,7 +56,8 @@ the composer loses focus. At wider sizes, scrolling the conversation also rests
5656except when scrolling toward the end while already there. When the thread-context strip has room,
5757the model and mode controls stay available beside the thread context; otherwise they return when the
5858composer is focused. Focus the composer or start typing to expand it again. New-thread layouts keep
59the full composer.
59the 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.
6061
6162At phone-sized web or desktop window widths, existing threads animate between their compact and
6263expanded layouts. Up to three image attachments remain visible in either resting layout, followed
packages/contracts/src/settings.test.ts+16
......@@ -228,6 +228,22 @@ describe("ClientSettings context window meter", () => {
228228 });
229229});
230230
231describe("ClientSettings composer collapse", () => {
232 it("collapses on blur and scroll by default and accepts opting out of each", () => {
233 const defaults = decodeClientSettings({});
234 expect(defaults.composerCollapseOnBlur).toBe(true);
235 expect(defaults.composerCollapseOnScroll).toBe(true);
236
237 const blurOff = decodeClientSettings({ composerCollapseOnBlur: false });
238 expect(blurOff.composerCollapseOnBlur).toBe(false);
239 expect(blurOff.composerCollapseOnScroll).toBe(true);
240
241 expect(
242 decodeClientSettingsPatch({ composerCollapseOnScroll: false }).composerCollapseOnScroll,
243 ).toBe(false);
244 });
245});
246
231247describe("ServerSettings thread settlement", () => {
232248 it("defaults merge settlement on and inactivity settlement to three days", () => {
233249 const settings = decodeServerSettings({});
packages/contracts/src/settings.ts+6
......@@ -319,6 +319,10 @@ export const ClientSettingsSchema = Schema.Struct({
319319 // Legacy context window meter. The composer hides it by default; users who
320320 // still want the old usage indicator can restore it from Settings.
321321 contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
322 // Desktop resting composer. Each trigger that settles an existing thread's
323 // composer into its single-line layout can be turned off on its own.
324 composerCollapseOnBlur: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
325 composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
322326 proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
323327 showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
324328 // Legacy sidebar (the original per-project tree). Deliberately a fresh key
......@@ -1165,6 +1169,8 @@ export const ClientSettingsPatch = Schema.Struct({
11651169 ),
11661170 planModeEnabled: Schema.optionalKey(Schema.Boolean),
11671171 contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),
1172 composerCollapseOnBlur: Schema.optionalKey(Schema.Boolean),
1173 composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean),
11681174 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),
11691175 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
11701176 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),