From 33b7bd9bf09cf443d69a74d22bfb462b911d0b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:03:51 +0100 Subject: [PATCH] fix(ui): hide composer focus hint on mobile (#3795) --- .../task/chat/chat-input-body.test.tsx | 60 +++++++- .../components/task/chat/chat-input-body.tsx | 11 +- .../mobile-slash-command-composer.spec.ts | 7 + .../tests/chat/slash-command-composer.spec.ts | 7 + docs/plans/mobile-composer-focus-hint/plan.md | 132 ++++++++++++++++ .../task-01-mobile-hint.md | 141 ++++++++++++++++++ .../ui/requirements/composer-focus-hint.md | 40 +++++ .../ui/system-design/composer-focus-hint.md | 55 +++++++ 8 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 docs/plans/mobile-composer-focus-hint/plan.md create mode 100644 docs/plans/mobile-composer-focus-hint/task-01-mobile-hint.md create mode 100644 docs/specs/ui/requirements/composer-focus-hint.md create mode 100644 docs/specs/ui/system-design/composer-focus-hint.md diff --git a/apps/web/components/task/chat/chat-input-body.test.tsx b/apps/web/components/task/chat/chat-input-body.test.tsx index a629c8e4ef..20ced47a0e 100644 --- a/apps/web/components/task/chat/chat-input-body.test.tsx +++ b/apps/web/components/task/chat/chat-input-body.test.tsx @@ -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
; + return
; }, })); @@ -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( @@ -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( + + + , + ); + + 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 = ( + + + + ); + 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", + ); }); }); diff --git a/apps/web/components/task/chat/chat-input-body.tsx b/apps/web/components/task/chat/chat-input-body.tsx index 94c356fda2..b2dc6cdf36 100644 --- a/apps/web/components/task/chat/chat-input-body.tsx +++ b/apps/web/components/task/chat/chat-input-body.tsx @@ -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) { 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} > - +
diff --git a/apps/web/e2e/tests/chat/mobile-slash-command-composer.spec.ts b/apps/web/e2e/tests/chat/mobile-slash-command-composer.spec.ts index b80f5579c9..821f41e0d1 100644 --- a/apps/web/e2e/tests/chat/mobile-slash-command-composer.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-slash-command-composer.spec.ts @@ -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"); diff --git a/apps/web/e2e/tests/chat/slash-command-composer.spec.ts b/apps/web/e2e/tests/chat/slash-command-composer.spec.ts index cb9c7a5a24..e91f965db0 100644 --- a/apps/web/e2e/tests/chat/slash-command-composer.spec.ts +++ b/apps/web/e2e/tests/chat/slash-command-composer.spec.ts @@ -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"); diff --git a/docs/plans/mobile-composer-focus-hint/plan.md b/docs/plans/mobile-composer-focus-hint/plan.md new file mode 100644 index 0000000000..1f3c8d7ffb --- /dev/null +++ b/docs/plans/mobile-composer-focus-hint/plan.md @@ -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. diff --git a/docs/plans/mobile-composer-focus-hint/task-01-mobile-hint.md b/docs/plans/mobile-composer-focus-hint/task-01-mobile-hint.md new file mode 100644 index 0000000000..42bbea0968 --- /dev/null +++ b/docs/plans/mobile-composer-focus-hint/task-01-mobile-hint.md @@ -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. diff --git a/docs/specs/ui/requirements/composer-focus-hint.md b/docs/specs/ui/requirements/composer-focus-hint.md new file mode 100644 index 0000000000..094a75336b --- /dev/null +++ b/docs/specs/ui/requirements/composer-focus-hint.md @@ -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) diff --git a/docs/specs/ui/system-design/composer-focus-hint.md b/docs/specs/ui/system-design/composer-focus-hint.md new file mode 100644 index 0000000000..fa240131fc --- /dev/null +++ b/docs/specs/ui/system-design/composer-focus-hint.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)