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
47 changes: 46 additions & 1 deletion apps/web/src/components/ChatView.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
threadWithHistory,
type FakeDriver,
} from "../../test/fakeEnvironment";
import { act, render } from "../../test/render";
import { act, fakeTimers, render } from "../../test/render";

// The socket is the only fake layer; see test/appScenarios.renders.tsx.
vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => {
Expand Down Expand Up @@ -258,3 +258,48 @@ it("Enter on an empty prompt resumes an interrupted thread", async () => {
),
);
});

it("opening a thread or returning to the window on a touch tablet does not focus the composer", async () => {
const realMatchMedia = window.matchMedia.bind(window);
using _touch = vi
.spyOn(window, "matchMedia")
.mockImplementation((query) => realMatchMedia(query === "(pointer: coarse)" ? "all" : query));
const first = threadWithHistory("thread-29469-a", 1);
const second = threadWithHistory("thread-29469-b", 1);
using _environment = installFakeEnvironment([first, second]);
// iPad Pro 11 in landscape: the desktop layout, above max-sm.
await page.viewport(1194, 834);
__setClientSettingsForTests({
...DEFAULT_CLIENT_SETTINGS,
onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
});
const router = getRouter(
createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${first.id}`] }),
);
await router.load();
await using _app = await render(<AppRoot router={router} />, {
// AppRoot brings its own router.
router: false,
});
const composer = page.getByTestId("composer-editor");
await expect.element(page.getByText(`Answer 0 for ${first.id}.`)).toBeVisible();

// Fake frames hold the next thread entry's focus frame until the test advances it.
using _timers = fakeTimers();
await act(() =>
router.navigate({
to: "/$environmentId/$threadId",
params: { environmentId: ENVIRONMENT_ID, threadId: second.id },
}),
);
await expect.element(page.getByText(`Answer 0 for ${second.id}.`)).toBeVisible();
vi.advanceTimersToNextFrame();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// Focusing the editor raises the on-screen keyboard, which shrinks the page on iPadOS.
expect(document.activeElement).not.toBe(composer.element());

// ChatView waits two frames after window focus before refocusing the composer.
window.dispatchEvent(new FocusEvent("focus"));
vi.advanceTimersToNextFrame();
vi.advanceTimersToNextFrame();
expect(document.activeElement).not.toBe(composer.element());
});
19 changes: 16 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,8 @@ type ChatViewProps =

const DismissedFallbackWindowSchema = Schema.NullOr(Schema.String);

const ignoreComposerFocus = () => {};

export default function ChatView(props: ChatViewProps) {
const {
environmentId,
Expand Down Expand Up @@ -713,6 +715,8 @@ export default function ChatView(props: ChatViewProps) {
}, [isWorkspaceFileDragActive]);
const prefersPlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
const isMobileViewport = useMediaQuery("max-sm");
// Focusing the composer without a tap raises the on-screen keyboard on touch devices.
const isCoarsePointer = useMediaQuery({ pointer: "coarse" });
const [workspaceLayoutElement, setWorkspaceLayoutElement] = useState<HTMLDivElement | null>(null);
const threadPanelPopoverAnchorRef = useRef<HTMLElement | null>(null);
const [terminalUiLaunchContext, setTerminalUiLaunchContext] =
Expand Down Expand Up @@ -2146,12 +2150,20 @@ export default function ChatView(props: ChatViewProps) {
workspaceLayoutElement,
!embedded,
terminalUiState.terminalOpen,
focusComposerStable,
isCoarsePointer ? ignoreComposerFocus : focusComposerStable,
);

// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener that refocuses the composer after the window regains focus
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener that refocuses the composer after the window regains focus on a fine pointer
useEffect(() => {
if (embedded || !activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return;
if (
embedded ||
!activeThread?.id ||
terminalUiState.terminalOpen ||
isMobileViewport ||
isCoarsePointer
) {
return;
}
let frame: number | null = null;
const onWindowFocus = () => {
if (frame !== null) window.cancelAnimationFrame(frame);
Expand All @@ -2178,6 +2190,7 @@ export default function ChatView(props: ChatViewProps) {
activeThread?.id,
embedded,
focusComposerStable,
isCoarsePointer,
isMobileViewport,
terminalUiState.terminalOpen,
]);
Expand Down
Loading