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;