diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 89e1a7fb19e37ed84b9521484450732f9b6a773c..6bf0e87c9324bd89778c527df6e44929a64ac61c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -27,7 +27,6 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, confirmThreadUnpin: false, contextWindowMeterEnabled: false, - composerCollapseOnBlur: false, composerCollapseOnScroll: true, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ce26f96515cc08ba42fc5dcd39ae9dccf00a0a58..4351739c604be5460fc879c317900fda81c4668e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -213,7 +213,6 @@ import { resetComposerScrollGesture, suppressActiveComposerScrollGesture, } from "./composerScrollGesture"; -import { selectionHoldsComposerOpen } from "./composerSelectionHold"; import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; function ComposerVideoThumbnail({ file }: { file: File }) { @@ -1813,7 +1812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerScrollCollapsed, setIsComposerScrollCollapsed, restoreAfterTimelineReachedEnd, - } = useComposerFocusState(isMobileViewport); + } = useComposerFocusState(); const [composerSubmissionError, setComposerSubmissionError] = useState(null); const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( null, @@ -1847,8 +1846,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandFrameRef = useRef(null); const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); - const desktopOutsidePointerInFlightRef = useRef(false); - const desktopOutsidePointerReleaseTimeoutRef = useRef(null); const composerScrollCollapseTimeoutRef = useRef(null); const composerScrollCollapseEligibleRef = useRef(false); const windowRefocusInFlightRef = useRef(false); @@ -3716,10 +3713,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isComposerResting = shouldUseRestingComposerLayout({ isExistingThread: routeKind === "server" && activeThreadId !== null, isMobileViewport, - isFocused: isComposerFocused, isScrollCollapsed: isComposerScrollCollapsed, hasExpandedChrome: composerHasExpandedChrome, - collapseOnBlur: settings.composerCollapseOnBlur, timelineOverflows, }); // The relocated controls live in the context strip whenever the composer is @@ -3804,9 +3799,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) settings.composerCollapseOnScroll && !composerHasExpandedChrome && !showInlineTasksBadge; - // Scrolling only has something to collapse while the composer is expanded. - // With blur collapse off that includes an unfocused composer, so the wheel - // handler keys off this rather than editor focus. + // Scrolling only has something to collapse while the composer is expanded, + // focused or not, so the wheel handler keys off the resting state rather + // than editor focus. composerScrollCollapseEligibleRef.current = canScrollCollapseComposer && !isComposerResting; useEffect(() => { @@ -4455,8 +4450,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => { void onImplementPlanInNewThread(); }, [onImplementPlanInNewThread]); + // The phone composer collapses when the editor loses focus. Desktop only + // rests on a timeline scroll, so losing focus there changes nothing. const scheduleComposerCollapseCheck = useCallback(() => { - if (isMobileViewport && mobileComposerExpandInFlightRef.current) { + if (!isMobileViewport || mobileComposerExpandInFlightRef.current) { return; } if (composerBlurFrameRef.current !== null) { @@ -4464,10 +4461,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } composerBlurFrameRef.current = window.requestAnimationFrame(() => { composerBlurFrameRef.current = null; - if (isMobileViewport && mobileComposerExpandInFlightRef.current) { - return; - } - if (!isMobileViewport && desktopOutsidePointerInFlightRef.current) { + if (mobileComposerExpandInFlightRef.current) { return; } const composerSurface = composerSurfaceRef.current; @@ -4483,108 +4477,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) { return; } - if ( - !isMobileViewport && - selectionHoldsComposerOpen(window.getSelection(), getTimelineScrollableNode()) - ) { - // The check runs again once the selection clears. - return; - } setIsComposerFocused(false); }); - }, [getTimelineScrollableNode, isMobileViewport, setIsComposerFocused]); - - // A held collapse settles when the selection goes away, whether the user - // clicked elsewhere, pressed Escape, or used the selection toolbar. - useEffect(() => { - if (isMobileViewport || !isComposerFocused) return; - let wasHolding = false; - const handleSelectionChange = () => { - const holding = selectionHoldsComposerOpen( - window.getSelection(), - getTimelineScrollableNode(), - ); - if (wasHolding && !holding) { - scheduleComposerCollapseCheck(); - } - wasHolding = holding; - }; - document.addEventListener("selectionchange", handleSelectionChange); - return () => { - document.removeEventListener("selectionchange", handleSelectionChange); - }; - }, [ - getTimelineScrollableNode, - isComposerFocused, - isMobileViewport, - scheduleComposerCollapseCheck, - ]); - - useEffect(() => { - if (isMobileViewport || !isComposerFocused) return; - - const isInsideDesktopComposerFocusScope = (target: EventTarget | null) => - Boolean( - target instanceof Node && - (composerFormRef.current?.contains(target) || isInsideRestingComposerControlScope(target)), - ); - const handleFocusIn = (event: FocusEvent) => { - if (!isInsideDesktopComposerFocusScope(event.target)) { - if (desktopOutsidePointerInFlightRef.current) { - return; - } - setIsComposerFocused(false); - } - }; - const handlePointerDown = (event: PointerEvent) => { - if (!isInsideDesktopComposerFocusScope(event.target)) { - desktopOutsidePointerInFlightRef.current = true; - if (desktopOutsidePointerReleaseTimeoutRef.current !== null) { - window.clearTimeout(desktopOutsidePointerReleaseTimeoutRef.current); - desktopOutsidePointerReleaseTimeoutRef.current = null; - } - } - }; - const finishOutsidePointerInteraction = () => { - desktopOutsidePointerInFlightRef.current = false; - if (desktopOutsidePointerReleaseTimeoutRef.current !== null) { - window.clearTimeout(desktopOutsidePointerReleaseTimeoutRef.current); - desktopOutsidePointerReleaseTimeoutRef.current = null; - } - scheduleComposerCollapseCheck(); - }; - const handlePointerUp = () => { - if (!desktopOutsidePointerInFlightRef.current) return; - desktopOutsidePointerReleaseTimeoutRef.current = window.setTimeout(() => { - if (desktopOutsidePointerInFlightRef.current) { - finishOutsidePointerInteraction(); - } - }, 0); - }; - const handleClick = () => { - if (desktopOutsidePointerInFlightRef.current) { - finishOutsidePointerInteraction(); - } - }; - - document.addEventListener("focusin", handleFocusIn, true); - document.addEventListener("pointerdown", handlePointerDown, true); - document.addEventListener("pointerup", handlePointerUp, true); - document.addEventListener("pointercancel", handlePointerUp, true); - document.addEventListener("click", handleClick); - return () => { - document.removeEventListener("focusin", handleFocusIn, true); - document.removeEventListener("pointerdown", handlePointerDown, true); - document.removeEventListener("pointerup", handlePointerUp, true); - document.removeEventListener("pointercancel", handlePointerUp, true); - document.removeEventListener("click", handleClick); - if (desktopOutsidePointerReleaseTimeoutRef.current !== null) { - window.clearTimeout(desktopOutsidePointerReleaseTimeoutRef.current); - desktopOutsidePointerReleaseTimeoutRef.current = null; - } - desktopOutsidePointerInFlightRef.current = false; - }; - }, [isComposerFocused, isMobileViewport, scheduleComposerCollapseCheck, setIsComposerFocused]); + }, [isMobileViewport, setIsComposerFocused]); useEffect(() => { return () => { @@ -4790,10 +4685,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerScrollCollapsed(false); if (isComposerResting && !target.closest('[data-testid="composer-editor"]')) { - // Clicking resting-surface padding would otherwise blur the still - // focused editor after pointerdown: expansion starts, the blur check - // runs, and it immediately collapses again. Treat that padding like - // the editor without stealing native caret placement from text. + // Clicking resting-surface padding would otherwise blur the editor. + // Treat that padding like the editor without stealing native caret + // placement from text. event.preventDefault(); setIsComposerFocused(true); scheduleComposerFocus(); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 115f4f739254d8dc1c3fadeb6a45e81966fd61c4..6893d713f024b7fd9e7a813cd5fa1d3ab60b55df 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -267,17 +267,17 @@ describe("MessagesTimeline", () => { getState: () => ({ isAtEnd: timelineIsAtEnd }), getScrollableNode: () => null, } as unknown as LegendListRef; - let isResting = true; + let isResting = false; + let composerState: ReturnType | undefined; function ThreadProbe() { - const composer = useComposerFocusState(false); + const composer = useComposerFocusState(); useLayoutEffect(() => { + composerState = composer; isResting = shouldUseRestingComposerLayout({ isExistingThread: true, isMobileViewport: false, - isFocused: composer.isComposerFocused, isScrollCollapsed: composer.isComposerScrollCollapsed, hasExpandedChrome: false, - collapseOnBlur: true, timelineOverflows: true, }); }); @@ -309,6 +309,8 @@ describe("MessagesTimeline", () => { await act(() => { renderer = create(); }); + // The user scrolled up to read, so the composer is resting. + await act(() => composerState!.setIsComposerScrollCollapsed(true)); const toggle = renderer!.root.findByProps({ "aria-expanded": false }); await act(() => toggle.props.onClick()); await flushFrame(); diff --git a/apps/web/src/components/chat/composerSelectionHold.test.ts b/apps/web/src/components/chat/composerSelectionHold.test.ts deleted file mode 100644 index fb4d52552a198125102bbcee91d78d6bc4ffe0a5..0000000000000000000000000000000000000000 --- a/apps/web/src/components/chat/composerSelectionHold.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { selectionHoldsComposerOpen } from "./composerSelectionHold"; - -function node(parent: Node | null = null) { - const self = { - parent, - contains(other: Node | null): boolean { - let cursor: { parent: Node | null } | null = other as unknown as { parent: Node | null }; - while (cursor) { - if (cursor === (self as unknown)) return true; - cursor = cursor.parent as { parent: Node | null } | null; - } - return false; - }, - }; - return self as unknown as Node; -} - -function selection(anchor: Node, collapsed = false) { - return { - isCollapsed: collapsed, - rangeCount: 1, - getRangeAt: () => ({ commonAncestorContainer: anchor }) as Range, - }; -} - -describe("selectionHoldsComposerOpen", () => { - const timeline = node(); - const message = node(timeline); - const elsewhere = node(); - - it("holds for a range inside the timeline", () => { - expect(selectionHoldsComposerOpen(selection(message), timeline)).toBe(true); - }); - - it("ignores a caret with nothing selected", () => { - expect(selectionHoldsComposerOpen(selection(message, true), timeline)).toBe(false); - }); - - it("ignores selections outside the timeline", () => { - expect(selectionHoldsComposerOpen(selection(elsewhere), timeline)).toBe(false); - }); - - it("ignores an empty or missing selection", () => { - expect(selectionHoldsComposerOpen(null, timeline)).toBe(false); - expect( - selectionHoldsComposerOpen( - { isCollapsed: false, rangeCount: 0, getRangeAt: () => ({}) as Range }, - timeline, - ), - ).toBe(false); - expect(selectionHoldsComposerOpen(selection(message), null)).toBe(false); - }); -}); diff --git a/apps/web/src/components/chat/composerSelectionHold.ts b/apps/web/src/components/chat/composerSelectionHold.ts deleted file mode 100644 index 9c75c631e97da176d35dbbdcd780cc6e104da9ba..0000000000000000000000000000000000000000 --- a/apps/web/src/components/chat/composerSelectionHold.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Whether a live text selection inside the timeline should hold the composer - * open. A drag-select in the conversation blurs the composer, and letting it - * rest mid-gesture reflows the timeline, which dismisses the selection - * toolbar the user was about to use. - */ -export function selectionHoldsComposerOpen( - selection: Pick | null, - timeline: Node | null, -): boolean { - if (!timeline || !selection || selection.isCollapsed || selection.rangeCount === 0) { - return false; - } - return timeline.contains(selection.getRangeAt(0).commonAncestorContainer); -} diff --git a/apps/web/src/components/chat/useComposerFocusState.test.tsx b/apps/web/src/components/chat/useComposerFocusState.test.tsx index 5d2ee7a3163cffe986d6b8b925418834fe10131d..b55f8e3ba443494d45e8e90bfe12379f780f876a 100644 --- a/apps/web/src/components/chat/useComposerFocusState.test.tsx +++ b/apps/web/src/components/chat/useComposerFocusState.test.tsx @@ -9,17 +9,15 @@ let root: Root; let composer: ReturnType; let isResting: boolean; -function ComposerProbe({ isMobileViewport = false }: { isMobileViewport?: boolean }) { - const state = useComposerFocusState(isMobileViewport); +function ComposerProbe() { + const state = useComposerFocusState(); useLayoutEffect(() => { composer = state; isResting = shouldUseRestingComposerLayout({ isExistingThread: true, - isMobileViewport, - isFocused: state.isComposerFocused, + isMobileViewport: false, isScrollCollapsed: state.isComposerScrollCollapsed, hasExpandedChrome: false, - collapseOnBlur: true, timelineOverflows: true, }); }); @@ -54,19 +52,13 @@ afterEach(async () => { }); describe("composer focus state", () => { - it("expands at the timeline end after a tool call takes focus", async () => { + it("stays expanded when the composer loses focus", async () => { await act(() => composer.setIsComposerFocused(true)); expect(isResting).toBe(false); - // A tool disclosure takes focus before the user scrolls through its output. + // A tool disclosure takes focus away from the editor. await act(() => composer.setIsComposerFocused(false)); - expect(isResting).toBe(true); - - await act(() => composer.restoreAfterTimelineReachedEnd()); expect(isResting).toBe(false); - - await act(() => composer.setIsComposerFocused(false)); - expect(isResting).toBe(true); }); it("can collapse again on the next scroll after returning to the end", async () => { @@ -80,9 +72,10 @@ describe("composer focus state", () => { expect(isResting).toBe(true); }); - it("does not expand the phone composer when the timeline reaches the end", async () => { - await act(() => root.render()); + it("does not move focus into the composer when the timeline reaches the end", async () => { + await act(() => composer.setIsComposerScrollCollapsed(true)); await act(() => composer.restoreAfterTimelineReachedEnd()); + expect(isResting).toBe(false); expect(composer.isComposerFocused).toBe(false); }); }); diff --git a/apps/web/src/components/chat/useComposerFocusState.ts b/apps/web/src/components/chat/useComposerFocusState.ts index c8858b33fa6be980df9250139cada35cacde5ff4..476fc0f9f09b23c0b455a1f92935f7c3be28e4ea 100644 --- a/apps/web/src/components/chat/useComposerFocusState.ts +++ b/apps/web/src/components/chat/useComposerFocusState.ts @@ -1,17 +1,14 @@ import { useCallback, useState } from "react"; -export function useComposerFocusState(isMobileViewport: boolean) { +export function useComposerFocusState() { const [isComposerFocused, setIsComposerFocused] = useState(false); const [isComposerScrollCollapsed, setIsComposerScrollCollapsed] = useState(false); + // Reaching the end of the timeline lifts a scroll collapse without moving + // DOM focus to the editor. const restoreAfterTimelineReachedEnd = useCallback(() => { setIsComposerScrollCollapsed(false); - // Restore the expanded layout after a timeline control takes focus too. - // This state holds the layout open without moving DOM focus to the editor. - if (!isMobileViewport) { - setIsComposerFocused(true); - } - }, [isMobileViewport]); + }, []); return { isComposerFocused, diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 7c85ead29b2710009722f4b81c326a8263b339e3..ec91146214c97045c3c68d7c8a14e4ce1b9d8fa9 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -100,47 +100,21 @@ describe("shouldUseRestingComposerLayout", () => { const resting = { isExistingThread: true, isMobileViewport: false, - isFocused: false, - isScrollCollapsed: false, + isScrollCollapsed: true, hasExpandedChrome: false, - collapseOnBlur: true, timelineOverflows: true, }; - it("uses the resting layout for an unfocused desktop composer", () => { + it("uses the resting layout after a timeline scroll", () => { expect(shouldUseRestingComposerLayout(resting)).toBe(true); }); - it("keeps an unfocused composer expanded when blur collapse is off", () => { - expect(shouldUseRestingComposerLayout({ ...resting, collapseOnBlur: false })).toBe(false); - }); - - it("rests a scroll-collapsed composer even while focused", () => { - expect( - shouldUseRestingComposerLayout({ ...resting, isFocused: true, isScrollCollapsed: true }), - ).toBe(true); - }); - - it("rests a scroll-collapsed composer regardless of the blur preference", () => { - expect( - shouldUseRestingComposerLayout({ - ...resting, - isFocused: true, - isScrollCollapsed: true, - collapseOnBlur: false, - }), - ).toBe(true); + it("keeps the composer expanded until the timeline is scrolled", () => { + expect(shouldUseRestingComposerLayout({ ...resting, isScrollCollapsed: false })).toBe(false); }); it("keeps the composer expanded while the timeline fits above it", () => { expect(shouldUseRestingComposerLayout({ ...resting, timelineOverflows: false })).toBe(false); - expect( - shouldUseRestingComposerLayout({ - ...resting, - isScrollCollapsed: true, - timelineOverflows: false, - }), - ).toBe(false); }); it("keeps new-thread composers expanded", () => { @@ -151,10 +125,6 @@ describe("shouldUseRestingComposerLayout", () => { expect(shouldUseRestingComposerLayout({ ...resting, isMobileViewport: true })).toBe(false); }); - it("expands when focus is anywhere in the composer", () => { - expect(shouldUseRestingComposerLayout({ ...resting, isFocused: true })).toBe(false); - }); - it("keeps drawers and composer-owned menus expanded", () => { expect(shouldUseRestingComposerLayout({ ...resting, hasExpandedChrome: true })).toBe(false); }); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 5dd6000dbc7576d93c0a1cf97655a93d32233c04..869b580f17237c676c0aac9ee1370bd5b0e7ca24 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -26,10 +26,8 @@ export function shouldUseCompactComposerFooter( export function shouldUseRestingComposerLayout(input: { isExistingThread: boolean; isMobileViewport: boolean; - isFocused: boolean; isScrollCollapsed: boolean; hasExpandedChrome: boolean; - collapseOnBlur: boolean; /** Whether the timeline has more content than fits above the composer. */ timelineOverflows: boolean; }): boolean { @@ -42,20 +40,19 @@ export function shouldUseRestingComposerLayout(input: { // desktop width, and where the strip is missing or too narrow the controls // simply return when the composer is focused. // - // A scroll collapse rests the composer regardless of the blur preference: - // the user asked for it with the gesture, and it lifts on the next - // composer interaction. With blur collapse off, losing focus alone never - // rests the composer. + // Only a timeline scroll rests the composer: the user asked for it with the + // gesture, and it lifts on the next composer interaction. Losing focus never + // rests it, so clicking a message, copying output, or selecting text for a + // citation leaves the composer where it was. // // Resting exists to give reading space back to the timeline. A thread that // fits above the composer has nothing to reclaim, so it stays expanded and // never shows the collapsed row that a fresh thread would otherwise open on. - const collapsed = input.isScrollCollapsed || (input.collapseOnBlur && !input.isFocused); return ( input.isExistingThread && !input.isMobileViewport && input.timelineOverflows && - collapsed && + input.isScrollCollapsed && !input.hasExpandedChrome ); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c3d5d7ba812ffeebd868749658c313e35f30d5b9..bac8f29373285c43e118cf5cde53d488c09790ea 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -174,12 +174,6 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; -const COMPOSER_COLLAPSE_TRIGGER_LABELS = { - blur: "On unfocus", - scroll: "On scroll", -} as const; -type ComposerCollapseTrigger = keyof typeof COMPOSER_COLLAPSE_TRIGGER_LABELS; - const DIFF_LAYOUT_LABELS: Record = { stacked: "Stacked", split: "Split", @@ -548,9 +542,8 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu ? ["Show skills in slash menu"] : []), - ...(settings.composerCollapseOnBlur !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur || - settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll - ? ["Collapse composer"] + ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll + ? ["Collapse composer on scroll"] : []), ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled ? ["Context window indicator"] @@ -609,7 +602,6 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadArchive, settings.confirmThreadDelete, settings.confirmThreadUnpin, - settings.composerCollapseOnBlur, settings.composerCollapseOnScroll, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, @@ -715,7 +707,6 @@ export function useSettingsRestore(onRestored?: () => void) { diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout, proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, - composerCollapseOnBlur: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur, composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, @@ -2028,13 +2019,6 @@ export function GeneralSettingsPanel() { const serverProviders = useAtomValue(primaryServerProvidersAtom); const supportsAutoSettlement = useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true; - const composerCollapseTriggers = useMemo( - () => [ - ...(settings.composerCollapseOnBlur ? (["blur"] as const) : []), - ...(settings.composerCollapseOnScroll ? (["scroll"] as const) : []), - ], - [settings.composerCollapseOnBlur, settings.composerCollapseOnScroll], - ); const diagnosticsDescription = formatDiagnosticsDescription({ localTracingEnabled: observability?.localTracingEnabled ?? false, otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, @@ -2361,16 +2345,14 @@ export function GeneralSettingsPanel() { updateSettings({ - composerCollapseOnBlur: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur, composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, }) } @@ -2378,34 +2360,13 @@ export function GeneralSettingsPanel() { ) : null } control={ - + aria-label="Collapse composer on scroll" + /> } /> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index dd4e357eb9d7a35732422b647985544a6da2270e..be06a37f308e55945252704da6ba7007b9be0fc5 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -214,11 +214,9 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "composer-collapse", - title: "Collapse composer", + title: "Collapse composer on scroll", to: "/settings/general", - searchTerms: [ - "composer rest resting unfocus blur focus click away scroll wheel conversation timeline shrink minimize", - ], + searchTerms: ["composer rest resting scroll wheel conversation timeline shrink minimize"], }, { id: "provider-update-checks", diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 22f5b5763f61f0b2eef3020dbf4d676ceba1647f..46b2c8eb1ba0d3579959bfeec55640488ad8bfe7 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -4,7 +4,7 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { Select as SelectPrimitive } from "@base-ui/react/select"; import { useRender } from "@base-ui/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; -import { CheckIcon, ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react"; +import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react"; import type * as React from "react"; import { cn } from "~/lib/utils"; @@ -180,32 +180,19 @@ function SelectItem({ className, children, hideIndicator: _hideIndicator = false, - showCheck = false, ...props }: SelectPrimitive.Item.Props & { hideIndicator?: boolean; - /** Render a check mark on selected items. Multi-selects use it so every chosen item is visible at once. */ - showCheck?: boolean; }) { return ( - {showCheck ? ( - - - - ) : null} { }); describe("ClientSettings composer collapse", () => { - it("collapses on blur and scroll by default and accepts opting out of each", () => { - const defaults = decodeClientSettings({}); - expect(defaults.composerCollapseOnBlur).toBe(true); - expect(defaults.composerCollapseOnScroll).toBe(true); - - const blurOff = decodeClientSettings({ composerCollapseOnBlur: false }); - expect(blurOff.composerCollapseOnBlur).toBe(false); - expect(blurOff.composerCollapseOnScroll).toBe(true); - + it("collapses on scroll by default and accepts opting out", () => { + expect(decodeClientSettings({}).composerCollapseOnScroll).toBe(true); expect( decodeClientSettingsPatch({ composerCollapseOnScroll: false }).composerCollapseOnScroll, ).toBe(false); }); + + it("drops the retired blur trigger key", () => { + const decoded = decodeClientSettings({ composerCollapseOnBlur: false }); + expect(decoded.composerCollapseOnScroll).toBe(true); + expect(decoded).not.toHaveProperty("composerCollapseOnBlur"); + }); }); describe("ServerSettings thread settlement", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1ddf66cde63ca05b8d0d6fec3353c099979a3501..7e25c8444dbb02cd20280e7f6e3872bddb83e07a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -337,9 +337,8 @@ export const ClientSettingsSchema = Schema.Struct({ // Legacy context window meter. The composer hides it by default; users who // still want the old usage indicator can restore it from Settings. contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - // Desktop resting composer. Each trigger that settles an existing thread's - // composer into its single-line layout can be turned off on its own. - composerCollapseOnBlur: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // Desktop resting composer: scrolling an existing thread's conversation + // settles the composer into its single-line layout. Losing focus never does. composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), @@ -1270,7 +1269,6 @@ export const ClientSettingsPatch = Schema.Struct({ ), planModeEnabled: Schema.optionalKey(Schema.Boolean), contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), - composerCollapseOnBlur: Schema.optionalKey(Schema.Boolean), composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),