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 = {...@@ -28,6 +28,8 @@ const clientSettings: ClientSettings = {
28 confirmThreadUnpin: false,28 confirmThreadUnpin: false,
29 continueThreadsAfterServerUpdate: true,29 continueThreadsAfterServerUpdate: true,
30 contextWindowMeterEnabled: false,30 contextWindowMeterEnabled: false,
31 composerCollapseOnBlur: false,
32 composerCollapseOnScroll: true,
31 dismissedProviderUpdateNotificationKeys: [],33 dismissedProviderUpdateNotificationKeys: [],
32 diffIgnoreWhitespace: true,34 diffIgnoreWhitespace: true,
33 diffLayout: "stacked",35 diffLayout: "stacked",
apps/web/src/components/chat/ChatComposer.tsx+12-10
...@@ -3542,8 +3542,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3542,8 +3542,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3542 const isComposerResting = shouldUseRestingComposerLayout({3542 const isComposerResting = shouldUseRestingComposerLayout({
3543 isExistingThread: routeKind === "server" && activeThreadId !== null,3543 isExistingThread: routeKind === "server" && activeThreadId !== null,
3544 isMobileViewport,3544 isMobileViewport,
3545 isFocused: isComposerFocused && !isComposerScrollCollapsed,3545 isFocused: isComposerFocused,
3546 isScrollCollapsed: isComposerScrollCollapsed,
3546 hasExpandedChrome: composerHasExpandedChrome,3547 hasExpandedChrome: composerHasExpandedChrome,
3548 collapseOnBlur: settings.composerCollapseOnBlur,
3547 });3549 });
3548 // The relocated controls live in the context strip whenever the composer is3550 // The relocated controls live in the context strip whenever the composer is
3549 // collapsed for any reason, the desktop resting layout or the phone3551 // collapsed for any reason, the desktop resting layout or the phone
...@@ -3615,8 +3617,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3615,8 +3617,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3615 const canTrackComposerScrollGesture =3617 const canTrackComposerScrollGesture =
3616 routeKind === "server" && activeThreadId !== null && !isMobileViewport;3618 routeKind === "server" && activeThreadId !== null && !isMobileViewport;
3617 const canScrollCollapseComposer =3619 const canScrollCollapseComposer =
3618 canTrackComposerScrollGesture && !composerHasExpandedChrome && !showInlineTasksBadge;3620 canTrackComposerScrollGesture &&
3619 composerScrollCollapseEligibleRef.current = canScrollCollapseComposer;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
3621 useEffect(() => {3629 useEffect(() => {
3622 if (!canScrollCollapseComposer) {3630 if (!canScrollCollapseComposer) {
...@@ -3657,11 +3665,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3657,11 +3665,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3657 resetComposerScrollGesture(composerScrollGestureRef.current);3665 resetComposerScrollGesture(composerScrollGestureRef.current);
3658 };3666 };
3659 const handleTimelineWheel = (event: WheelEvent) => {3667 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;
3665 if (event.ctrlKey || !(event.target instanceof Element)) {3668 if (event.ctrlKey || !(event.target instanceof Element)) {
3666 return;3669 return;
3667 }3670 }
...@@ -3695,8 +3698,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3695,8 +3698,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3695 now: window.performance.now(),3698 now: window.performance.now(),
3696 deltaPx,3699 deltaPx,
3697 collapseThresholdPx: COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX,3700 collapseThresholdPx: COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX,
3698 collapseEligible:3701 collapseEligible: targetsTimeline && composerScrollCollapseEligibleRef.current,
3699 targetsTimeline && composerScrollCollapseEligibleRef.current && isPromptEditorFocused,
3700 canScrollInGestureDirection,3702 canScrollInGestureDirection,
3701 scrollsTowardLogicalEnd: event.deltaY > 0 && isTimelineAtLogicalEnd(),3703 scrollsTowardLogicalEnd: event.deltaY > 0 && isTimelineAtLogicalEnd(),
3702 });3704 });
apps/web/src/components/composerFooterLayout.test.ts+23
...@@ -79,13 +79,36 @@ describe("shouldUseRestingComposerLayout", () => {...@@ -79,13 +79,36 @@ describe("shouldUseRestingComposerLayout", () => {
79 isExistingThread: true,79 isExistingThread: true,
80 isMobileViewport: false,80 isMobileViewport: false,
81 isFocused: false,81 isFocused: false,
82 isScrollCollapsed: false,
82 hasExpandedChrome: false,83 hasExpandedChrome: false,
84 collapseOnBlur: true,
83 };85 };
8486
85 it("uses the resting layout for an unfocused desktop composer", () => {87 it("uses the resting layout for an unfocused desktop composer", () => {
86 expect(shouldUseRestingComposerLayout(resting)).toBe(true);88 expect(shouldUseRestingComposerLayout(resting)).toBe(true);
87 });89 });
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
89 it("keeps new-thread composers expanded", () => {112 it("keeps new-thread composers expanded", () => {
90 expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false);113 expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false);
91 });114 });
apps/web/src/components/composerFooterLayout.ts+9-6
...@@ -27,7 +27,9 @@ export function shouldUseRestingComposerLayout(input: {...@@ -27,7 +27,9 @@ export function shouldUseRestingComposerLayout(input: {
27 isExistingThread: boolean;27 isExistingThread: boolean;
28 isMobileViewport: boolean;28 isMobileViewport: boolean;
29 isFocused: boolean;29 isFocused: boolean;
30 isScrollCollapsed: boolean;
30 hasExpandedChrome: boolean;31 hasExpandedChrome: boolean;
32 collapseOnBlur: boolean;
31}): boolean {33}): boolean {
32 // Passive draft content is deliberately absent here. Resting only clamps34 // Passive draft content is deliberately absent here. Resting only clamps
33 // the prompt row and overlays its actions; non-image attachment and context35 // the prompt row and overlays its actions; non-image attachment and context
...@@ -37,12 +39,13 @@ export function shouldUseRestingComposerLayout(input: {...@@ -37,12 +39,13 @@ export function shouldUseRestingComposerLayout(input: {
37 // deliberately absent here: resting reclaims vertical space at every39 // deliberately absent here: resting reclaims vertical space at every
38 // 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
39 // simply return when the composer is focused.41 // simply return when the composer is focused.
40 return (42 //
41 input.isExistingThread &&43 // A scroll collapse rests the composer regardless of the blur preference:
42 !input.isMobileViewport &&44 // the user asked for it with the gesture, and it lifts on the next
43 !input.isFocused &&45 // composer interaction. With blur collapse off, losing focus alone never
44 !input.hasExpandedChrome46 // rests the composer.
45 );47 const collapsed = input.isScrollCollapsed || (input.collapseOnBlur && !input.isFocused);
48 return input.isExistingThread && !input.isMobileViewport && collapsed && !input.hasExpandedChrome;
46}49}
4750
48export function shouldAnimateComposerRestingTransition(input: {51export function shouldAnimateComposerRestingTransition(input: {
apps/web/src/components/settings/SettingsPanels.tsx+71
...@@ -172,6 +172,12 @@ const TIMESTAMP_FORMAT_LABELS = {...@@ -172,6 +172,12 @@ const TIMESTAMP_FORMAT_LABELS = {
172 "24-hour": "24-hour",172 "24-hour": "24-hour",
173} as const;173} 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
175const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = {181const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = {
176 stacked: "Stacked",182 stacked: "Stacked",
177 split: "Split",183 split: "Split",
...@@ -542,6 +548,10 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -542,6 +548,10 @@ export function useSettingsRestore(onRestored?: () => void) {
542 ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu548 ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu
543 ? ["Show skills in slash menu"]549 ? ["Show skills in slash menu"]
544 : []),550 : []),
551 ...(settings.composerCollapseOnBlur !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur ||
552 settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll
553 ? ["Collapse composer"]
554 : []),
545 ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled555 ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled
546 ? ["Context window indicator"]556 ? ["Context window indicator"]
547 : []),557 : []),
...@@ -599,6 +609,8 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -599,6 +609,8 @@ export function useSettingsRestore(onRestored?: () => void) {
599 settings.confirmThreadArchive,609 settings.confirmThreadArchive,
600 settings.confirmThreadDelete,610 settings.confirmThreadDelete,
601 settings.confirmThreadUnpin,611 settings.confirmThreadUnpin,
612 settings.composerCollapseOnBlur,
613 settings.composerCollapseOnScroll,
602 settings.addProjectBaseDirectory,614 settings.addProjectBaseDirectory,
603 settings.defaultThreadEnvMode,615 settings.defaultThreadEnvMode,
604 settings.newWorktreesStartFromOrigin,616 settings.newWorktreesStartFromOrigin,
...@@ -703,6 +715,8 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -703,6 +715,8 @@ export function useSettingsRestore(onRestored?: () => void) {
703 diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout,715 diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout,
704 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,716 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
705 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,717 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
718 composerCollapseOnBlur: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur,
719 composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,
706 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,720 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,
707 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,721 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
708 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,722 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
...@@ -2010,6 +2024,13 @@ export function GeneralSettingsPanel() {...@@ -2010,6 +2024,13 @@ export function GeneralSettingsPanel() {
2010 const serverProviders = useAtomValue(primaryServerProvidersAtom);2024 const serverProviders = useAtomValue(primaryServerProvidersAtom);
2011 const supportsAutoSettlement =2025 const supportsAutoSettlement =
2012 useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true;2026 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 );
2013 const diagnosticsDescription = formatDiagnosticsDescription({2034 const diagnosticsDescription = formatDiagnosticsDescription({
2014 localTracingEnabled: observability?.localTracingEnabled ?? false,2035 localTracingEnabled: observability?.localTracingEnabled ?? false,
2015 otlpTracesEnabled: observability?.otlpTracesEnabled ?? false,2036 otlpTracesEnabled: observability?.otlpTracesEnabled ?? false,
...@@ -2334,6 +2355,56 @@ export function GeneralSettingsPanel() {...@@ -2334,6 +2355,56 @@ export function GeneralSettingsPanel() {
2334 }2355 }
2335 />2356 />
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
2337 <SettingsRow2408 <SettingsRow
2338 serverScoped2409 serverScoped
2339 {...searchableSetting("provider-update-checks")}2410 {...searchableSetting("provider-update-checks")}
apps/web/src/components/settings/settingsSearch.ts+8
...@@ -202,6 +202,14 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -202,6 +202,14 @@ export const SETTINGS_SEARCH_ITEMS = [
202 to: "/settings/general",202 to: "/settings/general",
203 searchTerms: ["command menu dollar $ slash /"],203 searchTerms: ["command menu dollar $ slash /"],
204 },204 },
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 },
205 {213 {
206 id: "provider-update-checks",214 id: "provider-update-checks",
207 title: "Provider update checks",215 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";...@@ -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 { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react";7import { CheckIcon, 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,19 +180,32 @@ function SelectItem({...@@ -180,19 +180,32 @@ function SelectItem({
180 className,180 className,
181 children,181 children,
182 hideIndicator: _hideIndicator = false,182 hideIndicator: _hideIndicator = false,
183 showCheck = false,
183 ...props184 ...props
184}: SelectPrimitive.Item.Props & {185}: SelectPrimitive.Item.Props & {
185 hideIndicator?: boolean;186 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;
186}) {189}) {
187 return (190 return (
188 <SelectPrimitive.Item191 <SelectPrimitive.Item
189 className={cn(192 className={cn(
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",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",
194 showCheck && "gap-2",
191 className,195 className,
192 )}196 )}
193 data-slot="select-item"197 data-slot="select-item"
194 {...props}198 {...props}
195 >199 >
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}
196 <SelectPrimitive.ItemText209 <SelectPrimitive.ItemText
197 className="min-w-0 flex-1 [&_svg:not([class*='text-'])]:text-muted-foreground"210 className="min-w-0 flex-1 [&_svg:not([class*='text-'])]:text-muted-foreground"
198 data-slot="select-item-text"211 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...@@ -56,7 +56,8 @@ the composer loses focus. At wider sizes, scrolling the conversation also rests
56except when scrolling toward the end while already there. When the thread-context strip has room,56except when scrolling toward the end while already there. When the thread-context strip has room,
57the model and mode controls stay available beside the thread context; otherwise they return when the57the model and mode controls stay available beside the thread context; otherwise they return when the
58composer is focused. Focus the composer or start typing to expand it again. New-thread layouts keep58composer is focused. Focus the composer or start typing to expand it again. 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
61At phone-sized web or desktop window widths, existing threads animate between their compact and62At phone-sized web or desktop window widths, existing threads animate between their compact and
62expanded layouts. Up to three image attachments remain visible in either resting layout, followed63expanded 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", () => {...@@ -228,6 +228,22 @@ describe("ClientSettings context window meter", () => {
228 });228 });
229});229});
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
231describe("ServerSettings thread settlement", () => {247describe("ServerSettings thread settlement", () => {
232 it("defaults merge settlement on and inactivity settlement to three days", () => {248 it("defaults merge settlement on and inactivity settlement to three days", () => {
233 const settings = decodeServerSettings({});249 const settings = decodeServerSettings({});
packages/contracts/src/settings.ts+6
...@@ -319,6 +319,10 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -319,6 +319,10 @@ export const ClientSettingsSchema = Schema.Struct({
319 // Legacy context window meter. The composer hides it by default; users who319 // Legacy context window meter. The composer hides it by default; users who
320 // still want the old usage indicator can restore it from Settings.320 // still want the old usage indicator can restore it from Settings.
321 contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),321 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))),
322 proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),326 proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
323 showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),327 showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
324 // Legacy sidebar (the original per-project tree). Deliberately a fresh key328 // Legacy sidebar (the original per-project tree). Deliberately a fresh key
...@@ -1165,6 +1169,8 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -1165,6 +1169,8 @@ export const ClientSettingsPatch = Schema.Struct({
1165 ),1169 ),
1166 planModeEnabled: Schema.optionalKey(Schema.Boolean),1170 planModeEnabled: Schema.optionalKey(Schema.Boolean),
1167 contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),1171 contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),
1172 composerCollapseOnBlur: Schema.optionalKey(Schema.Boolean),
1173 composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean),
1168 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),1174 proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),
1169 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),1175 showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
1170 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),1176 legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),