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");
@@ -0,0 +1,132 @@
---
created: 2026-09-17
status: done
requirements:
- REQ-UI-COMPOSER-FOCUS-HINT-001
system_design:
- ../../specs/ui/system-design/composer-focus-hint.md
legacy_specs: []
---
# Implementation Plan: Mobile Composer Focus Hint
## Overview
Hide the phone focus hint and reclaim its editor space in one sequential work
order. Source inspection confirms that current eligibility ignores viewport
width, and `ChatInputBody` uses that value for both hint and `pr-28` padding.
An empty, unfocused phone composer reproduces the defect.
## Scope
### In scope
Shared composer hint presentation, responsive regression coverage, and draft
preservation across the breakpoint.
### Out of scope
Executor configuration, cache cleanup, shortcut bindings, and tablet redesign.
## Technical approach
Apply the canonical `useResponsiveBreakpoint` mobile result in
`ChatInputBody`. Share one effective visibility predicate between hint and
padding. Preserve the state helper and all input handlers. UI owns this reusable
presentation contract; no durable architectural boundary changes.
## ASCII UI preview
UI-01: Empty, unfocused shared composer. Hint text is illustrative English;
existing localization remains authoritative. Toolbar structure is unchanged.
```text
Desktop >=768px: [ Write a message... / to focus ]
Phone before: [ Write a message... / to focus ]
Phone after: [ Write a message... ]
[ existing toolbar Send ]
```
The phone removes both hint and its reserved space (AC-001.1). Desktop keeps
existing eligibility (AC-001.2). The editor remains mounted across resize
(AC-001.3). This is a local region preview, not a pixel layout specification.
## Tests
Extend `chat-input-body.test.tsx` with mobile absence, no reserved padding,
wider visibility, and resize cases. The mobile case must fail on the existing
implementation before the production change. Retain the state helper tests in
`use-chat-input-container.test.ts` for existing eligibility.
## E2E tests
Extend `e2e/tests/chat/mobile-slash-command-composer.spec.ts` to check the hint
before tapping the empty editor, then retain the slash-command/send flow
(AC-001.1 and AC-001.3). Extend the desktop sibling with empty/unfocused hint
visibility and focus dismissal (AC-001.2). Scope assertions to the active
composer and inspect a phone screenshot. Use existing fixture readiness gates.
## Work orders
- [x] [Task 01: Gate composer focus hint on mobile](task-01-mobile-hint.md)
## Verification commands
```sh
(cd apps/web && pnpm exec vitest run components/task/chat/chat-input-body.test.tsx components/task/chat/use-chat-input-container.test.ts)
(cd apps/web && pnpm exec eslint components/task/chat/chat-input-body.tsx components/task/chat/chat-input-body.test.tsx e2e/tests/chat/mobile-slash-command-composer.spec.ts e2e/tests/chat/slash-command-composer.spec.ts)
(cd apps/web && pnpm e2e:run --project=mobile-chrome mobile-slash-command-composer.spec.ts)
(cd apps/web && pnpm e2e:run --project=chromium slash-command-composer.spec.ts)
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
## Verification results
Completed on 2026-09-17.
- RED: the new 393px and 767px component cases failed because the hint was
visible; 36 other tests passed before the production change.
- GREEN: the two focused Vitest suites passed, 38 tests total. Coverage includes
767px/768px boundaries, hint and padding changes in both resize directions,
editor identity, draft preservation, and existing state eligibility.
- ESLint passed with no warnings for the two component files and both modified
browser specs. Prettier was applied to changed code.
- Managed production-build E2E: mobile-chrome passed 1 test; chromium passed
4 tests. Phone typing, slash-command selection, and sending work; desktop
hint visibility and focus dismissal work.
- The phone test passed again with `--no-build` to regenerate its screenshot
after the desktop runner replaced the test output directory. Visual inspection
confirmed the hint is absent, editing space is available, and toolbar/send
controls remain visible. This reused the freshly verified production build.
- Specification catalog validation, full specification lint, and `git diff
--check` passed. The specification linter's 36 tests passed during design.
The initial browser attempts could not find Go. These successful E2E commands
used `PATH=/usr/local/go/bin:$PATH` in this Pod. No production workaround or
runner changes were needed. Unit tests supplied the defect-specific RED gate.
Public docs need no change: keyboard bindings, labels, and documented workflows
are unchanged. Internal requirements, design, and delivery records are updated.
Fresh synthetic desktop and phone PR screenshots were captured and visually
checked. Both capture runs passed. GitHub access and the user-approved commit
identity were restored for publication.
## Risks
Hiding only the hint leaves wasted padding. Using one effective predicate
prevents that mismatch. Do not redefine tablet or coarse-pointer policy.
## Review follow-up
Greptile identified that forced component rerenders could mask a broken
viewport subscription. Responsive cases now use native Happy DOM viewport
changes without parent rerenders. All 38 focused tests pass; disabling the
subscription makes the boundary-transition cases fail. ESLint passes.
Production behavior and screenshots are unchanged.
CodeRabbit identified an incomplete recorded ESLint command. The command now
lists both component files and both browser specs; rerunning it passed.
@@ -0,0 +1,141 @@
---
id: "01-mobile-hint"
title: "Gate composer focus hint on mobile"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-COMPOSER-FOCUS-HINT-001
acceptance_criteria:
- AC-UI-COMPOSER-FOCUS-HINT-001.1
- AC-UI-COMPOSER-FOCUS-HINT-001.2
- AC-UI-COMPOSER-FOCUS-HINT-001.3
system_design:
- ../../specs/ui/system-design/composer-focus-hint.md
---
# Task 01: Gate Composer Focus Hint on Mobile
## Summary
Suppress hint and reserved padding together below 768px. Keep desktop
eligibility and phone editing functional.
## In scope
One responsive presentation predicate and focused component/browser coverage.
## Out of scope
Executor files, new copy, keyboard binding changes, and composer redesign.
## Acceptance
- Mobile component regression fails before implementation and passes after it.
- Hint and padding disappear together on phone and recover on eligible wider
viewports; draft survives resizing.
- Phone slash-command and submission flow and desktop hint behavior pass.
## ASCII UI preview
UI-01: Empty, unfocused shared composer. Hint text is illustrative English;
existing localization remains authoritative. Toolbar structure is unchanged.
```text
Desktop >=768px: [ Write a message... / to focus ]
Phone before: [ Write a message... / to focus ]
Phone after: [ Write a message... ]
[ existing toolbar Send ]
```
The phone removes both hint and its reserved space (AC-001.1). Desktop keeps
existing eligibility (AC-001.2). The editor remains mounted across resize
(AC-001.3). This is a local region preview, not a pixel layout specification.
See the [full plan](plan.md#ascii-ui-preview).
## Verification
Run tests red first, apply the minimum fix, then run these checks sequentially.
Install workspace dependencies from `apps/` with `pnpm install --frozen-lockfile`
first if this checkout lacks them. Do not overlap browser suites.
```sh
(cd apps/web && pnpm exec vitest run components/task/chat/chat-input-body.test.tsx components/task/chat/use-chat-input-container.test.ts)
(cd apps/web && pnpm exec eslint components/task/chat/chat-input-body.tsx components/task/chat/chat-input-body.test.tsx e2e/tests/chat/mobile-slash-command-composer.spec.ts e2e/tests/chat/slash-command-composer.spec.ts)
(cd apps/web && pnpm e2e:run --project=mobile-chrome mobile-slash-command-composer.spec.ts)
(cd apps/web && pnpm e2e:run --project=chromium slash-command-composer.spec.ts)
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
## Files likely touched
- `apps/web/components/task/chat/chat-input-body.tsx`
- `apps/web/components/task/chat/chat-input-body.test.tsx`
- `apps/web/e2e/tests/chat/mobile-slash-command-composer.spec.ts`
- `apps/web/e2e/tests/chat/slash-command-composer.spec.ts`
## Dependencies
None.
## Risks
Assert both hint and padding. Reset responsive mocks between cases. Test
767px and 768px, and preserve desktop eligibility without widening scope.
## Parallelism
`sequential`
## Inputs
- [Requirements](../../specs/ui/requirements/composer-focus-hint.md)
- [System design](../../specs/ui/system-design/composer-focus-hint.md)
- Existing `shouldShowChatFocusHint` tests and mobile slash-command fixture.
## Results
Completed on 2026-09-17.
- RED: the new 393px and 767px component cases failed because the hint was
visible; 36 other tests passed before the production change.
- GREEN: the two focused Vitest suites passed, 38 tests total. Coverage includes
767px/768px boundaries, hint and padding changes in both resize directions,
editor identity, draft preservation, and existing state eligibility.
- ESLint passed with no warnings for the two component files and both modified
browser specs. Prettier was applied to changed code.
- Managed production-build E2E: mobile-chrome passed 1 test; chromium passed
4 tests. Phone typing, slash-command selection, and sending work; desktop
hint visibility and focus dismissal work.
- The phone test passed again with `--no-build` to regenerate its screenshot
after the desktop runner replaced the test output directory. Visual inspection
confirmed the hint is absent, editing space is available, and toolbar/send
controls remain visible. This reused the freshly verified production build.
- Specification catalog validation, full specification lint, and `git diff
--check` passed. The specification linter's 36 tests passed during design.
The initial browser attempts could not find Go. These successful E2E commands
used `PATH=/usr/local/go/bin:$PATH` in this Pod. No production workaround or
runner changes were needed. Unit tests supplied the defect-specific RED gate.
Public docs need no change: keyboard bindings, labels, and documented workflows
are unchanged. Internal requirements, design, and delivery records are updated.
Fresh synthetic desktop and phone PR screenshots were captured and visually
checked. Both capture runs passed. GitHub access and the user-approved commit
identity were restored for publication.
## Review follow-up
Greptile identified that forced component rerenders could mask a broken
viewport subscription. Responsive cases now use native Happy DOM viewport
changes without parent rerenders. All 38 focused tests pass; disabling the
subscription makes the boundary-transition cases fail. ESLint passes.
Production behavior and screenshots are unchanged.
CodeRabbit identified an incomplete recorded ESLint command. The command now
lists both component files and both browser specs; rerunning it passed.
@@ -0,0 +1,40 @@
---
status: active
system: ui
created: 2026-09-17
owners:
- kandev
---
# Composer Focus Hint Requirements
## Overview
UI owns the shared composer's responsive focus guidance, independent of agent
and task lifecycle. Phone users focus the editor by tapping it.
## Requirements
### REQ-UI-COMPOSER-FOCUS-HINT-001: Responsive focus guidance
**Intent:** Keep keyboard guidance from consuming phone editing space.
#### Acceptance criteria
- **AC-UI-COMPOSER-FOCUS-HINT-001.1:** Below 768 CSS pixels, the composer shall
omit the `/ to focus` hint and the editing space reserved solely for it,
including when the editor is empty and unfocused.
- **AC-UI-COMPOSER-FOCUS-HINT-001.2:** At widths of 768 CSS pixels or greater,
the composer shall retain its existing hint eligibility: empty after trimming,
unfocused, without an active clarification or pending review comments.
- **AC-UI-COMPOSER-FOCUS-HINT-001.3:** Resizing across the phone boundary shall
update hint visibility and reserved space without losing the draft. Tapping
the phone editor, typing slash commands, and sending shall remain available.
## Out of scope
Keyboard bindings, tablet policy changes, executor caches, and new copy.
## Implementation Plans
- [Mobile composer focus hint](../../../plans/mobile-composer-focus-hint/plan.md)
@@ -0,0 +1,55 @@
---
status: current
system: ui
requirements:
- REQ-UI-COMPOSER-FOCUS-HINT-001
---
# Composer Focus Hint System Design
## Purpose and boundaries
The shared composer owns presentation across task chat and quick chat. This
change affects neither session state nor shortcut dispatch.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| REQ-UI-COMPOSER-FOCUS-HINT-001 | Components and control flow |
## Components and control flow
`shouldShowChatFocusHint` in `use-chat-input-container.ts` currently derives
state eligibility without viewport information. Keep that state policy intact.
In `ChatInputBody`, read `isMobile` from `useResponsiveBreakpoint` and derive
one effective visibility value: `showFocusHint && !isMobile`. Use it for both
`ChatInputFocusHint.visible` and the editor's conditional `pr-28` class.
This keeps direct consumers and viewport transitions consistent and avoids
hiding only the hint while retaining wasted editor space.
## Mobile composition
Use the existing inline composer and mobile slash-command flow as the nearest
exemplar (`e2e/tests/chat/mobile-slash-command-composer.spec.ts`). The editor and
send action retain their order, touch targets, scroll owner, dynamic viewport,
and safe-area behavior. No additional drawer, navigation, or persisted state is
needed. Width below 768px defines phone scope even with a fine pointer.
## Compatibility and recovery
The existing reactive breakpoint hook updates on boundary changes without
remounting the editor. Wider touch devices retain current behavior. No new
translation, API, persistence, permission, logging, or metrics contract is
introduced. Existing editor focus and submission handlers are unchanged.
## Verification
Component tests drive the responsive hook through mobile and wider states,
checking hint absence and removal of reserved padding together. Existing state
predicate tests preserve wider eligibility. Playwright verifies phone tapping,
slash-command entry, submission, and wider-screen hint behavior.
## Implementation Plans
- [Mobile composer focus hint](../../../plans/mobile-composer-focus-hint/plan.md)