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)