fix(ui): hide composer focus hint on mobile (#3795)

This commit is contained in:
José Almeida
2026-09-18 16:03:51 +01:00
committed by GitHub
parent 6f975f7e22
commit 33b7bd9bf0
8 changed files with 447 additions and 6 deletions
@@ -1,17 +1,21 @@
import { createRef } from "react";
import { cleanup, render, screen } from "@testing-library/react";
import type { Window as HappyDOMWindow } from "happy-dom";
import { act, cleanup, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatInputBody, type ChatInputBodyProps } from "./chat-input-body";
import { shouldShowCancelAgent } from "./types";
const tipTapPropsMock = vi.hoisted(() => vi.fn());
const MOCK_TIPTAP_INPUT_TEST_ID = "mock-tiptap-input";
const initialWidth = window.innerWidth;
const viewport = (window as unknown as HappyDOMWindow).happyDOM;
const CHAT_INPUT_GLOW_TEST_ID = "chat-input-glow";
vi.mock("./tiptap-input", () => ({
TipTapInput: (props: unknown) => {
tipTapPropsMock(props);
return <div data-testid="mock-tiptap-input" />;
return <div data-testid={MOCK_TIPTAP_INPUT_TEST_ID} />;
},
}));
@@ -25,6 +29,7 @@ vi.mock("./context-items/context-zone", () => ({
afterEach(() => {
cleanup();
viewport.setWindowSize({ width: initialWidth });
tipTapPropsMock.mockClear();
});
@@ -152,7 +157,9 @@ describe("ChatInputBody", () => {
expect.objectContaining({ entityReferencesEnabled: false }),
);
});
});
describe("ChatInputBody focus hint", () => {
it("reserves right-side editable space while the focus hint is visible", () => {
render(
<TooltipProvider>
@@ -162,7 +169,50 @@ describe("ChatInputBody", () => {
expect(screen.getByText("to focus")).toBeTruthy();
expect(screen.getByTestId("chat-input-editor-shell").className).not.toContain("pr-28");
expect(screen.getByTestId("mock-tiptap-input").parentElement?.className).toContain("pr-28");
expect(screen.getByTestId(MOCK_TIPTAP_INPUT_TEST_ID).parentElement?.className).toContain(
"pr-28",
);
});
// @covers AC-UI-COMPOSER-FOCUS-HINT-001.1, AC-UI-COMPOSER-FOCUS-HINT-001.2
it.each([393, 767, 768, 1024])("matches focus hint and padding to viewport width %i", (width) => {
viewport.setWindowSize({ width });
render(
<TooltipProvider>
<ChatInputBody {...props({ showFocusHint: true })} />
</TooltipProvider>,
);
const hint = screen.queryByText("to focus");
const editor = screen.getByTestId(MOCK_TIPTAP_INPUT_TEST_ID);
expect(hint !== null).toBe(width >= 768);
expect(editor.parentElement?.classList.contains("pr-28")).toBe(width >= 768);
act(() => viewport.setWindowSize({ width: width < 768 ? 768 : 767 }));
expect(screen.getByTestId(MOCK_TIPTAP_INPUT_TEST_ID)).toBe(editor);
expect(screen.queryByText("to focus") !== null).toBe(width < 768);
expect(editor.parentElement?.classList.contains("pr-28")).toBe(width < 768);
});
// @covers AC-UI-COMPOSER-FOCUS-HINT-001.3
it("keeps the editor and draft when resizing across the phone boundary", () => {
const inputProps = props();
inputProps.editorAreaProps.value = "Draft to preserve";
const body = (
<TooltipProvider>
<ChatInputBody {...inputProps} />
</TooltipProvider>
);
viewport.setWindowSize({ width: 768 });
render(body);
const editor = screen.getByTestId(MOCK_TIPTAP_INPUT_TEST_ID);
act(() => viewport.setWindowSize({ width: 767 }));
expect(screen.getByTestId(MOCK_TIPTAP_INPUT_TEST_ID)).toBe(editor);
expect(tipTapPropsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ value: "Draft to preserve" }),
);
});
it("does not reserve focus-hint space when the hint is hidden", () => {
@@ -173,7 +223,9 @@ describe("ChatInputBody", () => {
);
expect(screen.getByTestId("chat-input-editor-shell").className).not.toContain("pr-28");
expect(screen.getByTestId("mock-tiptap-input").parentElement?.className).not.toContain("pr-28");
expect(screen.getByTestId(MOCK_TIPTAP_INPUT_TEST_ID).parentElement?.className).not.toContain(
"pr-28",
);
});
});
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { CompositorPulse } from "@kandev/ui/compositor-pulse";
import { cn } from "@/lib/utils";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { TipTapInput } from "./tiptap-input";
import { ChatInputFocusHint } from "./chat-input-focus-hint";
import { ResizeHandle } from "./resize-handle";
@@ -382,6 +383,11 @@ function useChatInputDrop(addFiles: (files: File[]) => Promise<void>) {
return { handleDragEnter, handleDragLeave, handleDragOver, handleDrop, isDragging };
}
function useChatFocusHintVisibility(showFocusHint: boolean): boolean {
const { isMobile } = useResponsiveBreakpoint();
return showFocusHint && !isMobile;
}
export function ChatInputBody({
containerRef,
height,
@@ -399,6 +405,7 @@ export function ChatInputBody({
editorAreaProps,
promptResultRecovery,
}: ChatInputBodyProps) {
const focusHintVisible = useChatFocusHintVisibility(showFocusHint);
const glowClass = chatInputGlowClass(isAgentBusy, isStarting);
const hasGlow = Boolean(glowClass);
const drop = useChatInputDrop(addFiles);
@@ -431,7 +438,7 @@ export function ChatInputBody({
onDragLeave={drop.handleDragLeave}
onDrop={drop.handleDrop}
>
<ChatInputFocusHint visible={showFocusHint} />
<ChatInputFocusHint visible={focusHintVisible} />
<ChatInputContextArea {...contextAreaProps} />
<div
ref={containerRef}
@@ -441,7 +448,7 @@ export function ChatInputBody({
>
<ChatInputEditorArea
{...editorAreaProps}
editorClassName={cn(editorAreaProps.editorClassName, showFocusHint && "pr-28")}
editorClassName={cn(editorAreaProps.editorClassName, focusHintVisible && "pr-28")}
/>
</div>
</div>
@@ -51,6 +51,13 @@ test.describe("Mobile slash command composer", () => {
// composerReady may reload to reconcile stale startup state. Seed the
// client-side command list only after that recovery so it is not discarded.
await seedAvailableCommands(testPage, task.session_id, [SLOW_COMMAND]);
// @covers AC-UI-COMPOSER-FOCUS-HINT-001.1
await editor.fill("");
await editor.blur();
const composer = session.chat.filter({ visible: true });
await expect(composer.getByText("to focus", { exact: true })).not.toBeVisible();
await expect(composer.locator(".pr-28")).toHaveCount(0);
await testPage.screenshot({ path: test.info().outputPath("mobile-composer.png") });
await editor.tap();
await editor.fill("");
await editor.pressSequentially("/s");
@@ -102,6 +102,13 @@ test.describe("Slash command composer", () => {
await seedAvailableCommands(testPage, task.session_id, [SLOW_COMMAND]);
const editor = chatEditor(testPage);
// @covers AC-UI-COMPOSER-FOCUS-HINT-001.2
await editor.fill("");
await editor.blur();
const composer = session.chat.filter({ visible: true });
await expect(composer.getByText("to focus", { exact: true })).toBeVisible();
await editor.click();
await expect(composer.getByText("to focus", { exact: true })).not.toBeVisible();
await selectSlowCommandWithEnter(testPage, editor);
const chatList = session.chat.locator(".chat-message-list:visible");