Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ const clientSettings: ClientSettings = {
confirmThreadDelete: false,
confirmThreadUnpin: false,
contextWindowMeterEnabled: false,
composerCollapseOnBlur: false,
composerCollapseOnScroll: true,
dismissedProviderUpdateNotificationKeys: [],
diffIgnoreWhitespace: true,
Expand Down
130 changes: 12 additions & 118 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,6 @@ import {
resetComposerScrollGesture,
suppressActiveComposerScrollGesture,
} from "./composerScrollGesture";
import { selectionHoldsComposerOpen } from "./composerSelectionHold";
import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";

function ComposerVideoThumbnail({ file }: { file: File }) {
Expand Down Expand Up @@ -1813,7 +1812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isComposerScrollCollapsed,
setIsComposerScrollCollapsed,
restoreAfterTimelineReachedEnd,
} = useComposerFocusState(isMobileViewport);
} = useComposerFocusState();
const [composerSubmissionError, setComposerSubmissionError] = useState<string | null>(null);
const [providerInputSubmissionError, setProviderInputSubmissionError] = useState<string | null>(
null,
Expand Down Expand Up @@ -1847,8 +1846,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const mobileComposerExpandFrameRef = useRef<number | null>(null);
const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);
const mobileComposerExpandInFlightRef = useRef(false);
const desktopOutsidePointerInFlightRef = useRef(false);
const desktopOutsidePointerReleaseTimeoutRef = useRef<number | null>(null);
const composerScrollCollapseTimeoutRef = useRef<number | null>(null);
const composerScrollCollapseEligibleRef = useRef(false);
const windowRefocusInFlightRef = useRef(false);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -4455,19 +4450,18 @@ 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) {
window.cancelAnimationFrame(composerBlurFrameRef.current);
}
composerBlurFrameRef.current = window.requestAnimationFrame(() => {
composerBlurFrameRef.current = null;
if (isMobileViewport && mobileComposerExpandInFlightRef.current) {
return;
}
if (!isMobileViewport && desktopOutsidePointerInFlightRef.current) {
if (mobileComposerExpandInFlightRef.current) {
return;
}
const composerSurface = composerSurfaceRef.current;
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand Down
10 changes: 6 additions & 4 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,17 +267,17 @@ describe("MessagesTimeline", () => {
getState: () => ({ isAtEnd: timelineIsAtEnd }),
getScrollableNode: () => null,
} as unknown as LegendListRef;
let isResting = true;
let isResting = false;
let composerState: ReturnType<typeof useComposerFocusState> | 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,
});
});
Expand Down Expand Up @@ -309,6 +309,8 @@ describe("MessagesTimeline", () => {
await act(() => {
renderer = create(<ThreadProbe />);
});
// 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();
Expand Down
55 changes: 0 additions & 55 deletions apps/web/src/components/chat/composerSelectionHold.test.ts

This file was deleted.

15 changes: 0 additions & 15 deletions apps/web/src/components/chat/composerSelectionHold.ts

This file was deleted.

23 changes: 8 additions & 15 deletions apps/web/src/components/chat/useComposerFocusState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,15 @@ let root: Root;
let composer: ReturnType<typeof useComposerFocusState>;
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,
});
});
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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(<ComposerProbe isMobileViewport />));
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);
});
});
11 changes: 4 additions & 7 deletions apps/web/src/components/chat/useComposerFocusState.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading
Loading