fix(ui): hide composer focus hint on mobile (#3795)
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user