fix(web): keep composer menus above mobile keyboards (#2988)

This commit is contained in:
José Almeida
2026-08-25 10:33:03 +00:00
committed by GitHub
parent 485361b41a
commit ecfba57c00
13 changed files with 853 additions and 16 deletions
@@ -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(
<PopupMenu
isOpen
testId="reflow-menu"
position={null}
clientRect={() => new DOMRect(16, 240, 1, 20)}
title="References"
@@ -70,15 +97,47 @@ describe("popup menu geometry", () => {
Result
</PopupMenu>,
);
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(
<PopupMenu
isOpen
testId="reflow-menu"
position={{ x: 16, y: 560 }}
title="References"
selectedIndex={0}
onClose={() => undefined}
>
Result
</PopupMenu>,
);
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");
});
});
+8 -4
View File
@@ -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,
@@ -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<HTMLElement>("[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);
}
});
@@ -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.
@@ -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.
@@ -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.
+2 -2
View File
@@ -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)
@@ -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
@@ -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.
@@ -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;
@@ -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.
@@ -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.
@@ -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;