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