From ecfba57c0014352c5505e23b98a6d71c7b7e92fc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jos=C3=A9=20Almeida?=
<57680069+zeval@users.noreply.github.com>
Date: Tue, 25 Aug 2026 10:33:03 +0000
Subject: [PATCH] fix(web): keep composer menus above mobile keyboards (#2988)
---
.../components/task/chat/popup-menu.test.tsx | 65 +++++-
apps/web/components/task/chat/popup-menu.tsx | 12 +-
.../mobile-prompt-mention-composer.spec.ts | 98 +++++++++
.../plan.md | 198 ++++++++++++++++++
.../task-01-clamp-composer-suggestions.md | 139 ++++++++++++
.../task-02-preserve-composer-adjacency.md | 112 ++++++++++
docs/specs/ui/README.md | 4 +-
.../agent-launch-prompt-composer.md | 5 +-
.../composer-suggestion-overlays.md | 75 +++++++
.../requirements/entity-reference-composer.md | 4 +-
.../ui/requirements/slash-command-composer.md | 4 +-
.../composer-suggestion-overlays.md | 149 +++++++++++++
.../entity-reference-composer.md | 4 +-
13 files changed, 853 insertions(+), 16 deletions(-)
create mode 100644 apps/web/e2e/tests/chat/mobile-prompt-mention-composer.spec.ts
create mode 100644 docs/plans/mobile-composer-suggestion-viewport/plan.md
create mode 100644 docs/plans/mobile-composer-suggestion-viewport/task-01-clamp-composer-suggestions.md
create mode 100644 docs/plans/mobile-composer-suggestion-viewport/task-02-preserve-composer-adjacency.md
create mode 100644 docs/specs/ui/requirements/composer-suggestion-overlays.md
create mode 100644 docs/specs/ui/system-design/composer-suggestion-overlays.md
diff --git a/apps/web/components/task/chat/popup-menu.test.tsx b/apps/web/components/task/chat/popup-menu.test.tsx
index 299e3e8a64..c5515c8b58 100644
--- a/apps/web/components/task/chat/popup-menu.test.tsx
+++ b/apps/web/components/task/chat/popup-menu.test.tsx
@@ -50,6 +50,32 @@ describe("popup menu geometry", () => {
expect(style.transform).toBe("translateY(-100%)");
});
+ // @covers AC-UI-COMPOSER-OVERLAY-001.1
+ it("clamps an above-composer menu to a software-keyboard visual viewport", () => {
+ const style = computePopupMenuStyle({
+ position: { x: 16, y: 560 },
+ placement: "above",
+ viewport: { offsetLeft: 0, offsetTop: 0, width: 393, height: 420 },
+ });
+
+ expect(style.top).toBe(412);
+ expect(Number(style.top) - Number(style.maxHeight)).toBeGreaterThanOrEqual(8);
+ });
+
+ it("preserves ordinary below-caret placement", () => {
+ const style = computePopupMenuStyle({
+ position: { x: 100, y: 100 },
+ placement: "below",
+ viewport: { offsetLeft: 0, offsetTop: 0, width: 1200, height: 800 },
+ });
+
+ expect(style.top).toBe(108);
+ expect(style.maxHeight).toBe(280);
+ expect(style.transform).toBeUndefined();
+ });
+});
+
+describe("popup menu viewport updates", () => {
it("reflows while the mobile visual viewport changes", () => {
const viewport = Object.assign(new EventTarget(), {
offsetLeft: 0,
@@ -61,6 +87,7 @@ describe("popup menu geometry", () => {
render(
new DOMRect(16, 240, 1, 20)}
title="References"
@@ -70,15 +97,47 @@ describe("popup menu geometry", () => {
Result
,
);
- const menu = screen.getByText("References").parentElement?.parentElement;
- expect(menu?.style.width).toBe("344px");
+ const menu = screen.getByTestId("reflow-menu");
+ expect(menu.style.width).toBe("344px");
act(() => {
viewport.width = 300;
viewport.dispatchEvent(new Event("resize"));
});
- expect(menu?.style.width).toBe("284px");
+ expect(menu.style.width).toBe("284px");
+ });
+
+ // @covers AC-UI-COMPOSER-OVERLAY-001.2
+ it("reflows an open menu above the software keyboard", () => {
+ const viewport = Object.assign(new EventTarget(), {
+ offsetLeft: 0,
+ offsetTop: 0,
+ width: 393,
+ height: 600,
+ });
+ Object.defineProperty(window, "visualViewport", { configurable: true, value: viewport });
+ render(
+ undefined}
+ >
+ Result
+ ,
+ );
+ const menu = screen.getByTestId("reflow-menu");
+ expect(menu.style.top).toBe("552px");
+
+ act(() => {
+ viewport.height = 420;
+ viewport.dispatchEvent(new Event("resize"));
+ });
+
+ expect(menu.style.top).toBe("412px");
});
});
diff --git a/apps/web/components/task/chat/popup-menu.tsx b/apps/web/components/task/chat/popup-menu.tsx
index 650816616a..0cda64706f 100644
--- a/apps/web/components/task/chat/popup-menu.tsx
+++ b/apps/web/components/task/chat/popup-menu.tsx
@@ -27,16 +27,20 @@ export function computePopupMenuStyle(args: {
const minLeft = args.viewport.offsetLeft + margin;
const maxLeft = Math.max(minLeft, viewportRight - width - margin);
const left = Math.min(Math.max(minLeft, args.position.x), maxLeft);
+ const minVerticalEdge = args.viewport.offsetTop + margin;
+ const maxVerticalEdge = Math.max(minVerticalEdge, viewportBottom - margin);
+ const requestedVerticalEdge =
+ args.placement === "above" ? args.position.y - margin : args.position.y + margin;
+ const verticalEdge = Math.min(Math.max(minVerticalEdge, requestedVerticalEdge), maxVerticalEdge);
const availableHeight =
args.placement === "above"
- ? Math.max(0, args.position.y - args.viewport.offsetTop - margin * 2)
- : Math.max(0, viewportBottom - args.position.y - margin * 2);
+ ? Math.max(0, verticalEdge - args.viewport.offsetTop - margin)
+ : Math.max(0, viewportBottom - verticalEdge - margin);
const maxHeight = Math.min(MENU_HEIGHT, availableHeight);
- const top = args.placement === "above" ? args.position.y - margin : args.position.y + margin;
return {
position: "fixed",
left,
- top,
+ top: verticalEdge,
width,
maxWidth: width,
maxHeight,
diff --git a/apps/web/e2e/tests/chat/mobile-prompt-mention-composer.spec.ts b/apps/web/e2e/tests/chat/mobile-prompt-mention-composer.spec.ts
new file mode 100644
index 0000000000..4576d09457
--- /dev/null
+++ b/apps/web/e2e/tests/chat/mobile-prompt-mention-composer.spec.ts
@@ -0,0 +1,98 @@
+import { type Page } from "@playwright/test";
+import { test, expect, type SeedData } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import { SessionPage } from "../../pages/session-page";
+
+const PROMPT_NAME = "mobile-prompt-mention-viewport";
+const PROMPT_CONTENT = "Mobile custom prompt content";
+
+async function openReadyTask(page: Page, apiClient: ApiClient, seedData: SeedData) {
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Mobile Prompt Mention Viewport",
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+ await page.goto(`/t/${task.id}`);
+ const session = new SessionPage(page);
+ await session.waitForLoad();
+ await session.waitForChatIdle({ timeout: 30_000 });
+ return session;
+}
+
+// @covers AC-UI-COMPOSER-OVERLAY-001.1
+// @covers AC-UI-COMPOSER-OVERLAY-001.3
+// @covers AC-UI-COMPOSER-OVERLAY-001.4
+// @covers AC-UI-COMPOSER-OVERLAY-001.5
+test("keeps custom prompts directly above a keyboard-resized mobile composer", async ({
+ testPage,
+ apiClient,
+ seedData,
+ prCapture,
+}) => {
+ const prompt = await apiClient.createPrompt(PROMPT_NAME, PROMPT_CONTENT);
+ try {
+ const session = await openReadyTask(testPage, apiClient, seedData);
+ const editor = await session.composerReady();
+ await editor.tap();
+ await editor.fill("");
+ const viewport = testPage.viewportSize();
+ expect(viewport).not.toBeNull();
+ // Playwright cannot open an OS keyboard. Resize the page itself so the
+ // mobile layout and focused composer reflow into the reduced visible area.
+ // Overriding visualViewport alone would describe the composer as occluded.
+ await testPage.setViewportSize({ width: viewport!.width, height: 420 });
+ await editor.pressSequentially("@mobile-prompt");
+
+ const menu = testPage.getByRole("listbox", { name: /Mention tasks, files, prompts/i });
+ const option = menu.getByRole("option").filter({ hasText: PROMPT_NAME });
+ await expect(menu).toBeVisible();
+ await expect(option).toBeVisible();
+
+ const [menuBox, composerBox] = await Promise.all([
+ menu.locator("..").boundingBox(),
+ session.activeChat().getByTestId("chat-input-editor-shell").boundingBox(),
+ ]);
+ expect(menuBox).not.toBeNull();
+ expect(composerBox).not.toBeNull();
+ expect(Math.abs(menuBox!.y + menuBox!.height - composerBox!.y)).toBeLessThanOrEqual(8);
+
+ const geometry = await menu.evaluate((listbox) => {
+ const surface = listbox.parentElement;
+ const rect = surface?.getBoundingClientRect();
+ return {
+ top: rect?.top ?? -1,
+ bottom: rect?.bottom ?? -1,
+ viewportTop: window.visualViewport?.offsetTop ?? 0,
+ viewportBottom: window.visualViewport
+ ? window.visualViewport.offsetTop + window.visualViewport.height
+ : window.innerHeight,
+ rowHeight: listbox.querySelector("[role='option']")?.getBoundingClientRect()
+ .height,
+ hasHorizontalOverflow:
+ document.documentElement.scrollWidth > document.documentElement.clientWidth,
+ };
+ });
+ expect(geometry.top).toBeGreaterThanOrEqual(geometry.viewportTop);
+ expect(geometry.bottom).toBeLessThanOrEqual(geometry.viewportBottom + 1);
+ expect(geometry.rowHeight).toBeGreaterThanOrEqual(44);
+ expect(geometry.hasHorizontalOverflow).toBe(false);
+
+ await prCapture.screenshot("mobile-composer-prompt-menu", {
+ caption:
+ "Saved-prompt suggestions stay attached directly above the keyboard-resized composer",
+ });
+
+ await option.tap();
+ await expect(menu).toHaveCount(0);
+ await expect(editor).toContainText(PROMPT_NAME);
+ await expect(editor).toBeFocused();
+ } finally {
+ await apiClient.deletePrompt(prompt.id).catch(() => undefined);
+ }
+});
diff --git a/docs/plans/mobile-composer-suggestion-viewport/plan.md b/docs/plans/mobile-composer-suggestion-viewport/plan.md
new file mode 100644
index 0000000000..fefff658cb
--- /dev/null
+++ b/docs/plans/mobile-composer-suggestion-viewport/plan.md
@@ -0,0 +1,198 @@
+---
+spec: docs/specs/ui/requirements/composer-suggestion-overlays.md
+system_design: docs/specs/ui/system-design/composer-suggestion-overlays.md
+created: 2026-08-23
+status: implemented
+---
+
+# Implementation Plan: Keep Mobile Composer Suggestions Visible
+
+## Overview
+
+Repair the shared popup geometry used by composer suggestion menus. A normal
+Pixel-sized browser run proved that typing `@mobile-prompt` opens the saved
+prompt menu and touch selection inserts the prompt chip. A second run modeled a
+software keyboard by shrinking `window.visualViewport.height` to 420 CSS
+pixels. The menu still rendered with its bottom at 551 pixels, leaving 131
+pixels behind the keyboard. The focused browser assertion failed as expected.
+The throwaway diagnostic spec was removed after recording this evidence.
+
+The root cause is in `computePopupMenuStyle`. It constrains width and maximum
+height with visual-viewport dimensions, but it derives the vertical anchor from
+the unchanged layout-viewport caret coordinate. The existing visual-viewport
+resize listener therefore rerenders the same off-screen vertical position.
+
+The default above placement is shared by chat `@` mentions (including custom
+prompts), `/` agent commands, `#` entity references, and the shared task/agent
+prompt composer. The plan editor's slash menu shares `PopupMenu` but requests
+below placement; it needs a geometry regression, not new behavior.
+
+PR screenshot feedback exposed a separate test-model defect. The browser test
+replaced only `window.visualViewport.height`, so it declared the still-rendered
+composer occluded without causing layout reflow. The shared popup correctly
+used its containment fallback at the visible bottom edge, producing a screenshot
+with a 136-pixel gap to the composer. Task 02 replaces that impossible visual
+state with an actual keyboard-sized page viewport and makes direct
+menu-to-composer adjacency a permanent browser assertion.
+
+## Requirement coverage
+
+| Acceptance criterion | Work order coverage |
+| --- | --- |
+| `AC-UI-COMPOSER-OVERLAY-001.1` | Unit geometry regression and mobile saved-prompt E2E in Task 01 |
+| `AC-UI-COMPOSER-OVERLAY-001.2` | Visual-viewport resize component regression in Task 01 |
+| `AC-UI-COMPOSER-OVERLAY-001.3` | Phone geometry, row-height, and overflow assertions in Task 01 |
+| `AC-UI-COMPOSER-OVERLAY-001.4` | Touch insertion and focus assertions plus sibling mobile suites in Task 01 |
+| `AC-UI-COMPOSER-OVERLAY-001.5` | Keyboard-sized layout and menu-to-composer bounding-box assertion in Task 02 |
+
+## Frontend
+
+- Add a failing `computePopupMenuStyle` regression where an above-placement
+ anchor remains below a shrunken visual viewport.
+- Normalize the rendered vertical edge against the current visual viewport
+ before calculating available height.
+- Preserve the existing eight-pixel inset, 420-pixel focused width, 280-pixel
+ height cap, short-list bottom anchoring, and body portal.
+- Keep the existing window and visual-viewport event subscriptions. No new
+ hook, store state, or dependency is required.
+- Add ordinary above- and below-placement cases so the fix cannot shift desktop
+ menus or the plan editor's below-caret menu.
+- Do not change mention queries, custom-prompt loading, entity-reference search,
+ agent command handling, localization, or insertion logic.
+
+## Tests
+
+- Extend `apps/web/components/task/chat/popup-menu.test.tsx` first. Link the new
+ cases to `AC-UI-COMPOSER-OVERLAY-001.1` and
+ `AC-UI-COMPOSER-OVERLAY-001.2` in nearby comments.
+- Prove that a raw anchor at `y=560` with a 420-pixel visual viewport yields a
+ rendered above-menu bottom no lower than the padded viewport bottom and a
+ maximum height calculated from that normalized edge.
+- Preserve the current offset-viewport, focused-width, short-list anchor, and
+ resize cases. Add a below-placement assertion for unchanged ordinary
+ geometry.
+
+## Mobile E2E
+
+- Add
+ `apps/web/e2e/tests/chat/mobile-prompt-mention-composer.spec.ts` using the
+ managed E2E fixture and `mobile-chrome` project.
+- Create a uniquely named custom prompt through the API, open a ready task, and
+ activate the real TipTap composer.
+- Keep the raw off-screen-anchor condition in deterministic geometry coverage.
+- For reviewer-visible browser evidence, resize the page to a keyboard-sized
+ height so the mobile layout and composer reflow together, then type a matching
+ `@` query.
+- Assert the popup surface is directly adjacent to the composer and inside the
+ visual viewport, its result row is at least 44 pixels high, and the document
+ has no horizontal overflow.
+- Tap the saved prompt and assert that the menu closes, the prompt appears in
+ the draft, the composer retains focus, and no message is sent.
+- Delete the created prompt in `finally` so the test remains isolated.
+- Run the existing mobile entity-reference, slash-command, and task-create
+ mention-menu scenarios as shared-consumer regressions.
+
+## Backend
+
+No backend, API, persistence, or migration change is required. The diagnostic
+run proved that the custom-prompt data reaches the menu when geometry is
+visible.
+
+## Verification commands
+
+Run the focused unit test during both red and green TDD steps:
+
+```bash
+cd apps/web && pnpm test -- components/task/chat/popup-menu.test.tsx
+```
+
+Run the rebuilt phone-browser scenarios after the unit regression passes:
+
+```bash
+cd apps/web && pnpm e2e:run --project mobile-chrome \
+ tests/chat/mobile-prompt-mention-composer.spec.ts \
+ tests/chat/mobile-entity-reference-composer.spec.ts \
+ tests/chat/mobile-slash-command-composer.spec.ts \
+ tests/task/mobile-task-create-escape.spec.ts
+```
+
+Run frontend static checks after implementation:
+
+```bash
+cd apps/web && pnpm run typecheck
+cd apps && pnpm --filter @kandev/web lint
+```
+
+## Verification results
+
+- Unit RED: `pnpm test -- components/task/chat/popup-menu.test.tsx` failed the
+ two new viewport regressions as expected (2 failed, 9 passed). Both received
+ the stale `552px` top instead of the contained `412px` top.
+- Browser RED: the new `mobile-chrome` saved-prompt scenario failed with popup
+ bottom `551px` beyond the simulated visual-viewport limit of `421px`.
+- Unit GREEN: the focused popup-menu suite passed (11 tests).
+- Browser GREEN: the rebuilt saved-prompt scenario passed (1 test), including
+ visual-viewport containment, a 44-pixel touch row, no document horizontal
+ overflow, touch insertion, and focus retention.
+- Shared-consumer GREEN: the rebuilt `mobile-chrome` command covering the new
+ `@` scenario, existing `#` entity-reference scenario, existing `/` command
+ scenario, and task-create mention-menu scenarios passed (5 tests).
+- `pnpm run typecheck` passed.
+- Full web lint initially reported the popup test callback over its line limit.
+ Splitting geometry and viewport-update groups resolved it; the final full web
+ lint passed with zero warnings.
+- PR review remediation qualified the legacy entity-reference anchoring text so
+ it matches the authoritative off-screen-anchor clamp. Specification lint
+ passed afterward.
+- Follow-up browser RED: the new adjacency assertion measured a 136-pixel gap
+ when only `visualViewport.height` changed and the composer remained below the
+ declared visible area.
+- Follow-up GREEN: resizing the page itself to 420 pixels reflowed the mobile
+ layout and composer together. The saved-prompt surface remained within eight
+ pixels of the composer while retaining viewport, touch-size, overflow,
+ insertion, and focus coverage.
+- Follow-up shared-consumer run passed all five targeted mobile `@`, `#`, `/`,
+ and task-create scenarios. The focused popup suite passed 11 tests; typecheck,
+ full web lint, and specification lint also passed.
+- Fresh phone evidence was captured and visually inspected at
+ `apps/web/.pr-assets/mobile-prompt-mention-composer--mobile-composer-prompt-menu.png`.
+
+## Implementation wave
+
+Execution stays in the primary conversation.
+
+Wave 1:
+
+- [x] [Task 01: Clamp composer suggestions to the visual viewport](task-01-clamp-composer-suggestions.md)
+
+Wave 2:
+
+- [x] [Task 02: Preserve visible composer adjacency](task-02-preserve-composer-adjacency.md)
+
+No task is parallel-safe, and this plan does not authorize subagents.
+
+## Risks
+
+- Computing maximum height from the raw anchor after clamping only `top` would
+ leave the surface internally inconsistent. Both values must use the same
+ normalized edge.
+- Replacing the existing transform with a fixed full-height box would regress
+ short-result bottom anchoring.
+- Browser E2E runners do not open a real operating-system keyboard. Raw
+ visual-viewport occlusion stays in deterministic geometry coverage; visual
+ browser evidence must resize page layout so the real composer and popup
+ reflow together.
+- A shared primitive change can affect the plan editor's below placement even
+ though the reported menus use above placement. Focused below-path coverage is
+ required.
+
+## Public documentation
+
+None. The fix restores expected responsive behavior and changes no public
+command, configuration key, API, or workflow.
+
+## Decisions
+
+No ADR is required. The selected change corrects existing viewport containment
+inside one shared UI primitive and introduces no durable architectural
+alternative.
diff --git a/docs/plans/mobile-composer-suggestion-viewport/task-01-clamp-composer-suggestions.md b/docs/plans/mobile-composer-suggestion-viewport/task-01-clamp-composer-suggestions.md
new file mode 100644
index 0000000000..c81d98f4b3
--- /dev/null
+++ b/docs/plans/mobile-composer-suggestion-viewport/task-01-clamp-composer-suggestions.md
@@ -0,0 +1,139 @@
+---
+id: "01-clamp-composer-suggestions"
+title: "Clamp composer suggestions to the visual viewport"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+spec: "../../specs/ui/requirements/composer-suggestion-overlays.md"
+system_design: "../../specs/ui/system-design/composer-suggestion-overlays.md"
+---
+
+# Task 01: Clamp Composer Suggestions to the Visual Viewport
+
+## Intent
+
+Keep mobile composer suggestion menus visible and selectable when a software
+keyboard shrinks the visual viewport but the editor anchor remains in layout
+coordinates.
+
+## Acceptance
+
+- `AC-UI-COMPOSER-OVERLAY-001.1`: An above-placement menu whose raw anchor is
+ below the visual viewport renders its full surface inside the padded visible
+ bounds when the header and a result row fit.
+- `AC-UI-COMPOSER-OVERLAY-001.2`: An already-open menu recomputes that contained
+ geometry after visual-viewport resize or scroll without retriggering.
+- `AC-UI-COMPOSER-OVERLAY-001.3`: The phone browser keeps the popup horizontally
+ contained, the document free of horizontal overflow, and each selectable row
+ at least 44 CSS pixels tall.
+- `AC-UI-COMPOSER-OVERLAY-001.4`: Touching the custom-prompt result inserts it,
+ closes the menu, preserves composer focus, and does not send the draft.
+- Ordinary desktop and offset-phone geometry remains unchanged. Below placement
+ retains its explicit top-edge semantics.
+- The unit and browser regressions fail before the production change and pass
+ after the smallest geometry correction.
+
+## Files likely touched
+
+- `apps/web/components/task/chat/popup-menu.tsx`
+- `apps/web/components/task/chat/popup-menu.test.tsx`
+- `apps/web/e2e/tests/chat/mobile-prompt-mention-composer.spec.ts`
+- `docs/plans/mobile-composer-suggestion-viewport/plan.md`
+- `docs/plans/mobile-composer-suggestion-viewport/task-01-clamp-composer-suggestions.md`
+
+## Steps
+
+1. Add the shrunken-visual-viewport unit case to
+ `popup-menu.test.tsx`, annotate its acceptance-criterion coverage, and run it
+ to record the expected failure.
+2. Add ordinary above- and below-placement assertions that pin existing
+ geometry.
+3. Change `computePopupMenuStyle` so the rendered vertical edge and available
+ height use the same visual-viewport-normalized coordinate. Keep the current
+ width, margin, height cap, transform, and event subscriptions.
+4. Run the focused unit suite to green and refactor only if the geometry stays
+ simpler and deterministic.
+5. Add the managed mobile E2E from the diagnostic scenario. Record its red
+ geometry failure before relying on the production correction, then run it to
+ green.
+6. Run the sibling mobile menu scenarios, typecheck, and lint. Record exact
+ results in this work order and the plan.
+
+## Verification
+
+```bash
+cd apps/web && pnpm test -- components/task/chat/popup-menu.test.tsx
+cd apps/web && pnpm e2e:run --project mobile-chrome \
+ tests/chat/mobile-prompt-mention-composer.spec.ts \
+ tests/chat/mobile-entity-reference-composer.spec.ts \
+ tests/chat/mobile-slash-command-composer.spec.ts \
+ tests/task/mobile-task-create-escape.spec.ts
+cd apps/web && pnpm run typecheck
+cd apps && pnpm --filter @kandev/web lint
+```
+
+## Dependencies
+
+None.
+
+## Parallelism
+
+Sequential in the primary conversation. Production geometry and its unit and
+browser regressions form one TDD loop. This work order does not authorize
+subagents.
+
+## Inputs
+
+- `REQ-UI-COMPOSER-OVERLAY-001` and its four acceptance criteria.
+- `docs/specs/ui/system-design/composer-suggestion-overlays.md`.
+- Root-cause evidence: popup bottom 551 pixels with a 420-pixel visual viewport
+ after typing a matching custom-prompt query.
+- Passing focused baseline: 23 tests across popup geometry, TipTap suggestion,
+ and custom-prompt hooks.
+- Existing mobile browser coverage in
+ `mobile-entity-reference-composer.spec.ts`,
+ `mobile-slash-command-composer.spec.ts`, and
+ `mobile-task-create-escape.spec.ts`.
+
+## Risks
+
+- Use visual-viewport offsets as well as height; an offset viewport can occur
+ during zoom or browser chrome movement.
+- Calculate maximum height from the normalized rendered edge, not the stale
+ caret anchor.
+- Preserve short-list anchoring and the plan editor's below placement.
+- Keep the E2E cleanup in `finally`; custom prompts are shared fixture data.
+- Do not add a hardcoded user-facing string. The production change needs no new
+ copy or locale key.
+
+## Output contract
+
+Report the red failure, geometry formula, permanent regression coverage, exact
+verification results, affected shared consumers, blockers, and remaining risks.
+Then mark this work order done and update the plan checkbox and results.
+
+## Results
+
+- Added acceptance-criterion-linked unit coverage for an above-composer anchor
+ left below a software-keyboard visual viewport, live viewport resize, and the
+ unchanged below-caret placement path.
+- RED: the focused unit suite failed both new containment checks with `552px`
+ instead of `412px` (2 failed, 9 passed).
+- RED: the permanent mobile saved-prompt E2E measured popup bottom `551px`
+ against a `421px` limit and failed the containment assertion.
+- Updated `computePopupMenuStyle` to clamp the requested rendered vertical edge
+ to the padded visual viewport, then calculate available height from that same
+ normalized edge. Existing width, height cap, transform, portal, and viewport
+ event subscriptions remain unchanged.
+- GREEN: the focused unit suite passed (11 tests).
+- GREEN: the rebuilt mobile saved-prompt scenario passed (1 test).
+- Shared regression: the rebuilt mobile `@`, `#`, `/`, and task-create mention
+ command passed (5 tests).
+- `pnpm run typecheck` passed.
+- Final full web lint passed with zero warnings after splitting the test groups
+ to satisfy the callback line limit.
+- PR review remediation qualified the legacy entity-reference anchoring text to
+ cover the below-viewport clamp; specification lint passed afterward.
+- Managed browser runs removed their artifacts and task-owned instances during
+ teardown. No blocker or known remaining risk remains in the work-order scope.
diff --git a/docs/plans/mobile-composer-suggestion-viewport/task-02-preserve-composer-adjacency.md b/docs/plans/mobile-composer-suggestion-viewport/task-02-preserve-composer-adjacency.md
new file mode 100644
index 0000000000..7fcddbdf70
--- /dev/null
+++ b/docs/plans/mobile-composer-suggestion-viewport/task-02-preserve-composer-adjacency.md
@@ -0,0 +1,112 @@
+---
+id: "02-preserve-composer-adjacency"
+title: "Preserve visible composer adjacency"
+status: done
+wave: 2
+depends_on: ["01-clamp-composer-suggestions"]
+plan: "plan.md"
+spec: "../../specs/ui/requirements/composer-suggestion-overlays.md"
+system_design: "../../specs/ui/system-design/composer-suggestion-overlays.md"
+---
+
+# Task 02: Preserve Visible Composer Adjacency
+
+## Intent
+
+Keep suggestion lists directly above a visible mobile composer while retaining
+the off-screen-anchor containment fallback added in Task 01.
+
+## Acceptance
+
+- `AC-UI-COMPOSER-OVERLAY-001.1`: Raw anchors outside the visible viewport
+ remain covered by deterministic containment tests.
+- `AC-UI-COMPOSER-OVERLAY-001.3`: A keyboard-sized phone layout keeps the menu
+ contained, touch-safe, and free of horizontal overflow.
+- `AC-UI-COMPOSER-OVERLAY-001.4`: Touch selection still inserts the saved
+ prompt and preserves focus.
+- `AC-UI-COMPOSER-OVERLAY-001.5`: A visible mobile composer and suggestion
+ surface have no more than an eight-pixel vertical gap.
+
+## Files likely touched
+
+- `apps/web/e2e/tests/chat/mobile-prompt-mention-composer.spec.ts`
+- `docs/specs/ui/requirements/composer-suggestion-overlays.md`
+- `docs/specs/ui/system-design/composer-suggestion-overlays.md`
+- `docs/plans/mobile-composer-suggestion-viewport/plan.md`
+- `docs/plans/mobile-composer-suggestion-viewport/task-02-preserve-composer-adjacency.md`
+
+## Steps
+
+1. Add a menu-to-composer bounding-box assertion to the current mobile prompt
+ scenario and record its failure against the occlusion-only viewport mock.
+2. Resize the page to the keyboard-sized height so layout, composer, and visual
+ viewport reflow together. Keep the existing containment, touch-size,
+ overflow, insertion, and focus assertions.
+3. Capture and inspect fresh phone evidence, then run the focused and sibling
+ shared-menu checks.
+
+## Verification
+
+```bash
+cd apps/web && pnpm test -- components/task/chat/popup-menu.test.tsx
+cd apps/web && pnpm e2e:run --project mobile-chrome \
+ tests/chat/mobile-prompt-mention-composer.spec.ts \
+ tests/chat/mobile-entity-reference-composer.spec.ts \
+ tests/chat/mobile-slash-command-composer.spec.ts \
+ tests/task/mobile-task-create-escape.spec.ts
+cd apps/web && pnpm run typecheck
+cd apps && pnpm --filter @kandev/web lint
+python3 scripts/lint-spec-files.py --all
+```
+
+## Dependencies
+
+Task 01 supplies shared visual-viewport containment behavior and unit coverage.
+This remediation changes browser modeling and durable adjacency coverage, not
+production geometry.
+
+## Parallelism
+
+Sequential in the primary conversation. This work order does not authorize
+subagents.
+
+## Inputs
+
+- User screenshot feedback showing the list detached above the composer.
+- RED browser measurement: a 136-pixel menu-to-composer gap after replacing
+ only `visualViewport.height` while leaving page layout unchanged.
+- Existing unit coverage for an anchor below a reduced visual viewport.
+
+## Risks
+
+- Do not remove the raw off-screen-anchor containment regression while fixing
+ screenshot evidence.
+- Do not claim an operating-system keyboard is present in Playwright. The
+ browser runner models its reduced available height by resizing the page.
+- Keep capture assets ignored and publish them through PR media, not feature
+ branch history.
+
+## Output contract
+
+Report the RED gap, corrected viewport model, permanent adjacency assertion,
+fresh inspected screenshot, exact verification results, and PR status. Then
+mark this work order done and synchronize the plan.
+
+## Results
+
+- RED: the permanent bounding-box assertion measured a 136-pixel gap between
+ the saved-prompt surface and composer under the old occlusion-only viewport
+ mock.
+- Replaced that invalid visual model with `page.setViewportSize()` at a
+ 420-pixel height, which reflows the real mobile page, composer, and visual
+ viewport together. Production containment geometry did not require change.
+- GREEN: the focused scenario passed with direct adjacency, visual-viewport
+ containment, a 44-pixel result row, no horizontal overflow, touch insertion,
+ and retained composer focus.
+- The full targeted mobile shared-consumer run passed five tests covering `@`,
+ `#`, `/`, and task-create mention behavior.
+- Focused popup unit coverage passed 11 tests. Typecheck, full web lint, and
+ specification lint passed.
+- Captured and visually inspected
+ `apps/web/.pr-assets/mobile-prompt-mention-composer--mobile-composer-prompt-menu.png`;
+ the menu is directly attached above the composer.
diff --git a/docs/specs/ui/README.md b/docs/specs/ui/README.md
index 72cb31b82f..d2c0653522 100644
--- a/docs/specs/ui/README.md
+++ b/docs/specs/ui/README.md
@@ -37,8 +37,6 @@ contract that other capabilities can reuse.
### Requirements
-
-
- [ACP Model Configuration Summary](requirements/acp-model-configuration-summary.md)
- [ACP Shell Command Output](requirements/acp-shell-command-output.md)
- [Adaptive Kanban](requirements/adaptive-kanban.md)
@@ -56,6 +54,7 @@ contract that other capabilities can reuse.
- [Clarification submit feedback](requirements/clarification-submit-feedback.md)
- [Command-panel Sidebar Task Reveal](requirements/command-panel-sidebar-task-reveal.md)
- [Comment Markdown Rendering](requirements/comment-markdown.md)
+- [Composer Suggestion Overlays](requirements/composer-suggestion-overlays.md)
- [Context Compaction Count](requirements/context-compaction-count.md)
- [Context Window Reset Freshness](requirements/context-window-reset-freshness.md)
- [Context Window Unmeasured State](requirements/context-window-unmeasured-state.md)
@@ -143,6 +142,7 @@ contract that other capabilities can reuse.
- [Agent Todo List Panel](system-design/agent-todo-list-panel.md)
- [App Status Bar](system-design/app-status-bar.md)
+- [Composer Suggestion Overlays](system-design/composer-suggestion-overlays.md)
- [Per-workflow column visibility on the kanban board System Design Part 1](system-design/board-step-visibility-filter-01.md)
- [Per-workflow column visibility on the kanban board System Design Part 2](system-design/board-step-visibility-filter-02.md)
- [Task PR Automation Controls System Design Part 1](system-design/ci-pr-automation-01.md)
diff --git a/docs/specs/ui/requirements/agent-launch-prompt-composer.md b/docs/specs/ui/requirements/agent-launch-prompt-composer.md
index f9f6452c01..9fb8cd33b7 100644
--- a/docs/specs/ui/requirements/agent-launch-prompt-composer.md
+++ b/docs/specs/ui/requirements/agent-launch-prompt-composer.md
@@ -2,6 +2,7 @@
status: active
system: ui
created: 2026-08-02
+updated: 2026-08-23
owners:
- kandev
---
@@ -25,7 +26,7 @@ Starting another agent inside an existing task is another prompt-authoring flow,
- **AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.4:** Copying the initial prompt, choosing blank context, or generating a session summary updates that same composer without losing its authoring tools.
- **AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.5:** The Start Agent button and the existing Ctrl/Cmd+Enter shortcut launch with the current prompt and attachments. Enter used to select an open mention result never launches the agent.
- **AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.6:** The Start Agent action remains disabled while the prompt is empty, while context is being summarized, while launch is in progress, or when no compatible agent profile is available.
-- **AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.7:** Desktop and phone layouts expose the same capabilities and launch result. On phone, the existing mobile sessions entry point opens the dialog; its prompt menu and controls remain touch reachable without horizontal document overflow.
+- **AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.7:** Desktop and phone layouts expose the same capabilities and launch result. On phone, the existing mobile sessions entry point opens the dialog; the prompt menu follows the shared [composer suggestion overlay requirements](composer-suggestion-overlays.md), and the controls remain touch reachable without horizontal document overflow.
- **AC-UI-AGENT-LAUNCH-PROMPT-COMPOSER-001.8:** Agent launch continues to use the existing session launch contract, task environment, context selection, profile compatibility rules, and guarded enhancement delivery.
## Migrated source detail
@@ -42,7 +43,7 @@ Starting another agent inside an existing task is another prompt-authoring flow,
- Copying the initial prompt, choosing blank context, or generating a session summary updates that same composer without losing its authoring tools.
- The Start Agent button and the existing Ctrl/Cmd+Enter shortcut launch with the current prompt and attachments. Enter used to select an open mention result never launches the agent.
- The Start Agent action remains disabled while the prompt is empty, while context is being summarized, while launch is in progress, or when no compatible agent profile is available.
-- Desktop and phone layouts expose the same capabilities and launch result. On phone, the existing mobile sessions entry point opens the dialog; its prompt menu and controls remain touch reachable without horizontal document overflow.
+- Desktop and phone layouts expose the same capabilities and launch result. On phone, the existing mobile sessions entry point opens the dialog; the prompt menu follows the shared [composer suggestion overlay requirements](composer-suggestion-overlays.md), and the controls remain touch reachable without horizontal document overflow.
- Agent launch continues to use the existing session launch contract, task environment, context selection, profile compatibility rules, and guarded enhancement delivery.
## Failure modes
diff --git a/docs/specs/ui/requirements/composer-suggestion-overlays.md b/docs/specs/ui/requirements/composer-suggestion-overlays.md
new file mode 100644
index 0000000000..f4e43f0c12
--- /dev/null
+++ b/docs/specs/ui/requirements/composer-suggestion-overlays.md
@@ -0,0 +1,75 @@
+---
+status: active
+system: ui
+created: 2026-08-23
+updated: 2026-08-24
+owners:
+ - web
+---
+
+# Composer Suggestion Overlay Requirements
+
+## Overview
+
+Kandev composers expose contextual suggestion menus for saved prompts, tasks,
+files, plans, agent commands, and external work items. On a phone, opening the
+software keyboard can shrink the browser's visual viewport without moving the
+composer's layout-viewport caret coordinates. The user must still be able to
+see and select suggestions without dismissing the keyboard.
+
+## Terminology
+
+- **Layout viewport:** The coordinate space used by the editor caret and fixed
+ layout before mobile keyboard occlusion is applied.
+- **Visual viewport:** The currently visible browser region after zoom, browser
+ chrome, and software keyboard occlusion.
+- **Suggestion overlay:** The shared popup surface opened by a composer trigger,
+ including `@`, `#`, and `/` menus.
+
+## Requirements
+
+### REQ-UI-COMPOSER-OVERLAY-001: Keep composer suggestions usable in the visible viewport
+
+**Intent:** Preserve suggestion discovery and selection when a mobile software
+keyboard changes the visible browser area.
+
+**User story:** As a phone user composing a prompt, I want contextual
+suggestions to remain visible above the keyboard, so that I can insert an item
+without hiding the keyboard or abandoning my draft.
+
+#### Acceptance criteria
+
+- **AC-UI-COMPOSER-OVERLAY-001.1:** Given an open above-composer suggestion
+ overlay, when the current visual viewport no longer contains the caret or
+ direct anchor because a software keyboard or browser chrome changed the
+ visible area, the complete rendered overlay surface shall be positioned and
+ height-constrained inside the visual viewport whenever that viewport can fit
+ the overlay header and one result row.
+- **AC-UI-COMPOSER-OVERLAY-001.2:** Given an open suggestion overlay, when the
+ visual viewport resizes or scrolls, the overlay shall reflow without requiring
+ the user to close, retrigger, or retype the active query. When the Visual
+ Viewport API is unavailable, the layout viewport shall provide the fallback
+ bounds.
+- **AC-UI-COMPOSER-OVERLAY-001.3:** Given a result list taller or wider than the
+ available phone viewport, the overlay shall remain horizontally contained,
+ keep result rows at least 44 CSS pixels tall, and scroll its results internally
+ without introducing horizontal document overflow.
+- **AC-UI-COMPOSER-OVERLAY-001.4:** Given a visible saved-prompt, task, file,
+ plan, command, or entity-reference result, when the user selects it by touch
+ or keyboard, the existing feature-specific insertion behavior shall complete,
+ the draft shall not submit implicitly, and composer focus shall remain
+ available for continued editing.
+- **AC-UI-COMPOSER-OVERLAY-001.5:** Given the composer and its above-composer
+ anchor remain inside the visual viewport, when a suggestion overlay opens or
+ reflows, its rendered bottom edge shall remain directly adjacent to the
+ composer rather than detach to another viewport edge.
+
+## Out of scope
+
+- Changing suggestion result sources, ranking, filtering, or empty states.
+- Changing saved-prompt, task, file, plan, command, or entity-reference data
+ contracts.
+- Replacing the contextual popup with a drawer or full-screen picker.
+- Adding automatic placement-side flipping to the plan editor's below-caret
+ block menu.
+- Changing message submission, task launch, or agent launch behavior.
diff --git a/docs/specs/ui/requirements/entity-reference-composer.md b/docs/specs/ui/requirements/entity-reference-composer.md
index 479a934e24..745c12603d 100644
--- a/docs/specs/ui/requirements/entity-reference-composer.md
+++ b/docs/specs/ui/requirements/entity-reference-composer.md
@@ -2,7 +2,7 @@
status: active
system: ui
created: 2026-07-21
-updated: 2026-08-17
+updated: 2026-08-24
owners:
- kandev
---
@@ -21,7 +21,7 @@ Users discuss external work items in agent chat, but plain ticket keys and pull-
#### Acceptance criteria
- **AC-UI-ENTITY-REFERENCE-COMPOSER-001.1:** Task chat and Quick Chat support a `#` entity-reference trigger in their shared TipTap composer. Passthrough, task creation, comments, plans, Office text inputs, and other editors remain unchanged.
-- **AC-UI-ENTITY-REFERENCE-COMPOSER-001.2:** Typing `#` at the start of a text block or after whitespace opens a search menu directly above the composer. Its rendered bottom edge stays anchored to the composer even when only a short result set is visible. A `#` inside another token or a code block remains literal text.
+- **AC-UI-ENTITY-REFERENCE-COMPOSER-001.2:** Typing `#` at the start of a text block or after whitespace opens a search menu directly above the composer. Its shared geometry and visible-viewport containment follow the [composer suggestion overlay requirements](composer-suggestion-overlays.md). Its rendered bottom edge stays anchored to a composer anchor inside the visible viewport even when only a short result set is visible; an anchor below that viewport is clamped to its padded bottom edge. A `#` inside another token or a code block remains literal text.
- **AC-UI-ENTITY-REFERENCE-COMPOSER-001.3:** A trigger starts only when the user enters a new `#` character. Pasted, dropped, restored, or programmatically inserted text remains literal and does not open the menu.
- **AC-UI-ENTITY-REFERENCE-COMPOSER-001.4:** After the user types at least one query character, search covers the active workspace's connected, searchable sources:
- **AC-UI-ENTITY-REFERENCE-COMPOSER-001.5:** Jira tickets;
diff --git a/docs/specs/ui/requirements/slash-command-composer.md b/docs/specs/ui/requirements/slash-command-composer.md
index 39de4505a7..206d39b0bb 100644
--- a/docs/specs/ui/requirements/slash-command-composer.md
+++ b/docs/specs/ui/requirements/slash-command-composer.md
@@ -2,6 +2,7 @@
status: active
system: ui
created: 2026-07-02
+updated: 2026-08-23
owners:
- cfl
---
@@ -19,7 +20,7 @@ ACP agents can advertise slash commands during session setup. Kandev exposes tho
#### Acceptance criteria
-- **AC-UI-SLASH-COMMAND-COMPOSER-001.1:** When a chat composer has advertised agent commands and the user types `/`, the composer shows matching slash commands from the active session.
+- **AC-UI-SLASH-COMMAND-COMPOSER-001.1:** When a chat composer has advertised agent commands and the user types `/`, the composer shows matching slash commands from the active session. The menu follows the shared [composer suggestion overlay requirements](composer-suggestion-overlays.md).
- **AC-UI-SLASH-COMMAND-COMPOSER-001.2:** Selecting a slash command with Enter, Tab, or pointer/touch replaces only the active slash trigger range with a visually distinct inline command chip in the composer. It MUST NOT submit, queue, or otherwise send a chat message.
- **AC-UI-SLASH-COMMAND-COMPOSER-001.3:** The inserted command chip remains part of the editable draft. The user can add text before or after it, delete it, or leave the composer without starting an agent turn.
- **AC-UI-SLASH-COMMAND-COMPOSER-001.4:** The command chip serializes to the same plain slash command text when submitted, copied from the draft value, or saved as a draft.
@@ -37,6 +38,7 @@ ACP agents can advertise slash commands during session setup. Kandev exposes tho
## What
- When a chat composer has advertised agent commands and the user types `/`, the composer shows matching slash commands from the active session.
+- The menu's shared geometry and visible-viewport containment follow the [composer suggestion overlay requirements](composer-suggestion-overlays.md).
- Selecting a slash command with Enter, Tab, or pointer/touch replaces only the active slash trigger range with a visually distinct inline command chip in the composer. It MUST NOT submit, queue, or otherwise send a chat message.
- The inserted command chip remains part of the editable draft. The user can add text before or after it, delete it, or leave the composer without starting an agent turn.
- The command chip serializes to the same plain slash command text when submitted, copied from the draft value, or saved as a draft.
diff --git a/docs/specs/ui/system-design/composer-suggestion-overlays.md b/docs/specs/ui/system-design/composer-suggestion-overlays.md
new file mode 100644
index 0000000000..bff7c8b417
--- /dev/null
+++ b/docs/specs/ui/system-design/composer-suggestion-overlays.md
@@ -0,0 +1,149 @@
+---
+status: current
+system: ui
+updated: 2026-08-24
+requirements:
+ - REQ-UI-COMPOSER-OVERLAY-001
+---
+
+# Composer Suggestion Overlay System Design
+
+## Purpose and boundaries
+
+The shared `PopupMenu` primitive positions composer suggestion surfaces from a
+direct point or an editor-provided client rectangle. This design corrects its
+vertical geometry when mobile browsers retain layout-viewport caret coordinates
+below a software-keyboard-reduced visual viewport.
+
+The primitive owns viewport containment, maximum size, reflow subscriptions,
+listbox structure, touch-row size, and focus-preserving pointer behavior. Each
+consumer continues to own trigger recognition, result loading, selection, and
+draft serialization.
+
+## Requirement mapping
+
+| Requirement | Design section |
+| --- | --- |
+| `REQ-UI-COMPOSER-OVERLAY-001` | [Geometry and reflow](#geometry-and-reflow), [Interaction contract](#interaction-contract) |
+
+## Components and responsibilities
+
+- `PopupMenu` in `apps/web/components/task/chat/popup-menu.tsx` resolves the
+ anchor, reads viewport bounds, subscribes to viewport changes, portals the
+ surface to `document.body`, and renders one labelled listbox.
+- `computePopupMenuStyle` performs deterministic horizontal and vertical
+ geometry without reading the DOM. It is the unit-test boundary for viewport
+ containment.
+- `MentionMenu`, `SlashCommandMenu`, and `EntityReferenceMenu` use the default
+ above-anchor placement for chat and shared prompt-composer suggestions.
+- `PlanSlashMenu` uses the same primitive with below-anchor placement. Its
+ placement semantics are not changed, but unit coverage must protect that path
+ from a shared-geometry regression.
+
+## Data and contracts
+
+`PopupMenu` accepts one of two transient anchor contracts:
+
+- a direct `{x, y}` fixed-position point; or
+- a `clientRect` callback whose top or bottom edge supplies the placement point.
+
+The browser's `window.visualViewport`, when present, supplies `offsetLeft`,
+`offsetTop`, `width`, and `height`. Otherwise `window.innerWidth` and
+`window.innerHeight` supply a zero-offset fallback viewport. No value is stored
+or sent over an API.
+
+The existing placement contract remains explicit: `above` renders the
+surface's bottom edge above its normalized anchor; `below` renders the surface's
+top edge below its normalized anchor. This repair does not introduce automatic
+side flipping. Normalization is a containment fallback, not the primary
+placement: a visible direct anchor remains unchanged so the overlay stays
+attached to its composer.
+
+## Geometry and reflow
+
+`computePopupMenuStyle` shall use the current viewport rectangle for both axes:
+
+1. Derive padded viewport bounds with the existing eight-pixel margin.
+2. Clamp popup width and left position to the padded horizontal bounds.
+3. For `above`, derive the rendered bottom edge from `position.y - margin` and
+ clamp that edge to the padded vertical bounds. Calculate available height
+ from the clamped edge to the padded viewport top, not from the raw layout
+ anchor. Keep the existing `translateY(-100%)` transform so short result sets
+ stay bottom-anchored. When the requested edge is already inside the bounds,
+ clamping must be an identity operation so the surface remains directly
+ adjacent to the composer.
+4. For `below`, retain top-edge placement and calculate height from its
+ normalized top edge to the padded viewport bottom. Cover this path so the
+ above-placement repair cannot change its transform or ordinary geometry.
+5. Cap the outer surface at the existing menu-height limit, subtract the header
+ height for the internally scrollable listbox, and never produce a negative
+ size.
+
+When the software keyboard leaves a composer anchor below the visual viewport,
+step 3 moves the rendered bottom edge to the visible viewport's padded bottom.
+The list then grows upward into visible space. Anchors already inside the
+viewport retain their current geometry and composer adjacency.
+
+While open, `PopupMenu` continues to rerender on window resize and on visual
+viewport resize or scroll. The geometry helper then recomputes from the current
+bounds. No polling, global store, or new observer is required.
+
+## Interaction contract
+
+The popup remains a body portal above dialog content. Its title labels one
+`listbox`; each selectable row remains an `option` with a minimum 44-pixel touch
+height. Pointer-down continues to prevent composer blur, and selection remains
+owned by the invoking feature. This contextual, transient action list stays a
+popup on mobile; it is not a navigation flow that warrants a drawer.
+
+## Control flow
+
+1. A composer recognizes `@`, `#`, or `/` and supplies a direct point or live
+ client rectangle.
+2. `PopupMenu` resolves the placement point and current viewport.
+3. `computePopupMenuStyle` returns the contained fixed-position rectangle.
+4. A viewport event increments the local revision and repeats steps 2 and 3.
+5. The consumer handles touch or keyboard selection and updates the draft.
+
+## Failure and recovery
+
+- Without Visual Viewport API support, the menu uses the layout viewport and
+ preserves current desktop behavior.
+- If the visible viewport is too small for the header and one row, geometry
+ remains non-negative and contained; the browser may expose less usable
+ content until the viewport expands.
+- If the composer anchor itself is occluded, containment takes precedence over
+ adjacency because no placement can be both attached to that hidden anchor and
+ visible. Once layout reflow brings the anchor into view, direct adjacency is
+ restored.
+- A missing anchor keeps the menu closed. A failed result lookup remains the
+ invoking feature's empty or error state and does not affect geometry.
+- Closing and retriggering remains available, but viewport reflow must not
+ require it.
+
+## Persistence
+
+None. Viewport bounds, menu state, selected index, and result scrolling are
+transient browser state.
+
+## Security
+
+The change introduces no new trust boundary. Consumer-provided labels and
+descriptions continue through React rendering, and the popup does not evaluate
+result data or URLs.
+
+## Observability
+
+No production telemetry is added. Deterministic geometry unit tests cover raw
+layout anchors outside a shrunken visual viewport. A phone-browser E2E covers a
+saved-prompt result after the page and composer reflow to a keyboard-sized
+viewport, including direct composer adjacency, containment, touch selection,
+and focus retention. Existing mobile `#` and `/` scenarios provide
+sibling-consumer regressions. Browser evidence must resize the page layout with
+the visible viewport; replacing only `visualViewport` describes an occluded
+composer and is not valid visual evidence for adjacency.
+
+## Related decisions
+
+None. This is a local correction to an existing shared presentation contract
+and does not establish a new architecture boundary.
diff --git a/docs/specs/ui/system-design/entity-reference-composer.md b/docs/specs/ui/system-design/entity-reference-composer.md
index 16343822f9..c597a32020 100644
--- a/docs/specs/ui/system-design/entity-reference-composer.md
+++ b/docs/specs/ui/system-design/entity-reference-composer.md
@@ -4,7 +4,7 @@ system: ui
requirements:
- REQ-UI-ENTITY-REFERENCE-COMPOSER-001
created: 2026-07-21
-updated: 2026-08-17
+updated: 2026-08-24
owners:
- kandev
---
@@ -29,7 +29,7 @@ Users discuss external work items in agent chat, but plain ticket keys and pull-
## What
- Task chat and Quick Chat support a `#` entity-reference trigger in their shared TipTap composer. Passthrough, task creation, comments, plans, Office text inputs, and other editors remain unchanged.
-- Typing `#` at the start of a text block or after whitespace opens a search menu directly above the composer. Its rendered bottom edge stays anchored to the composer even when only a short result set is visible. A `#` inside another token or a code block remains literal text.
+- Typing `#` at the start of a text block or after whitespace opens a search menu directly above the composer. Its shared geometry and visible-viewport containment follow the [composer suggestion overlay requirements](../requirements/composer-suggestion-overlays.md). Its rendered bottom edge stays anchored to a composer anchor inside the visible viewport even when only a short result set is visible; an anchor below that viewport is clamped to its padded bottom edge. A `#` inside another token or a code block remains literal text.
- A trigger starts only when the user enters a new `#` character. Pasted, dropped, restored, or programmatically inserted text remains literal and does not open the menu.
- After the user types at least one query character, search covers the active workspace's connected, searchable sources:
- Jira tickets;