fix(ui): keep slash autocomplete scrollable in dialogs (#11222)

<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip helps operators manage AI-agent companies.
> - Operators create tasks and comments through shared rich-text
editors.
> - These editors show slash-command and mention matches in a floating
menu.
> - Modal dialogs treat that body-level menu as outside content and
cancel its wheel and touch movement.
> - This pull request keeps scroll events inside the floating menu and
preserves native scrolling.
> - The benefit is that operators can reach every match with a mouse
wheel, a trackpad, or a touch screen.

## Linked Issues or Issue Description

No public GitHub issue exists for this bug.

**What happened?**

Slash-command and mention menus could contain more matches than their
visible height. When an editor was inside a modal dialog, the modal
scroll lock canceled wheel and touch movement on the body-level menu
portal. Operators could not scroll to later matches.

**Expected behavior**

The autocomplete menu must scroll with a mouse wheel, a two-finger
trackpad gesture, and a vertical touch gesture. Keyboard selection and
normal editor behavior must stay unchanged.

**Steps to reproduce**

1. Open a task or comment editor inside a modal dialog.
2. Enter a slash command or mention query that has more matches than the
menu can show.
3. Try to scroll the menu with a wheel, trackpad, or touch gesture.

**Paperclip version or commit**

`7ea2068ef8` on `master`.

**Deployment mode**

Local development UI built from source.

## What Changed

- Keep wheel and touch movement inside the shared autocomplete menu
portal.
- Add vertical overscroll containment while preserving native momentum
scrolling.
- Add a regression test that mounts the real dialog and verifies that
wheel and touch movement stay uncanceled.

## Verification

- `pnpm --dir ui exec vitest run src/components/MarkdownEditor.test.tsx`
- `pnpm check:token-gates`
- `pnpm -r typecheck`
- `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm test:run`
- `pnpm build`

The two AWS variables are omitted from the full test command because
this agent runtime injects static AWS credentials. One unrelated CLI
doctor test correctly warns when those credentials are present. The CI
environment does not inject them.

## Risks

- Low risk. Event propagation stops only on the open autocomplete menu
portal.
- Ancestor listeners no longer receive wheel or touch movement from that
menu. Native menu scrolling and option-level touch handling still
receive the events.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex with model ID `gpt-5`. The deployment suffix and
context-window size are not exposed to the agent. The model used agentic
reasoning, repository tools, GitHub tools, and local code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-11 13:59:57 -04:00 committed by GitHub
parent 3e1ea39ff3
commit 7734f4b32d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 70 additions and 1 deletions

View File

@ -15,6 +15,7 @@ import {
placeCaretAfterMentionAnchor,
shouldAcceptAutocompleteKey,
} from "./MarkdownEditor";
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
const mdxEditorMockState = vi.hoisted(() => ({
emitMountEmptyReset: false,
@ -1029,6 +1030,64 @@ describe("MarkdownEditor", () => {
});
});
it("lets wheel and touch scrolling reach the autocomplete menu inside a modal", async () => {
const root = createRoot(container);
const mentions = Array.from({ length: 12 }, (_, index) => ({
id: `project:project-${index}`,
kind: "project" as const,
name: `Paperclip App ${index}`,
projectId: `project-${index}`,
projectColor: "#336699",
}));
await act(async () => {
root.render(
<Dialog open>
<DialogContent>
<DialogTitle>Create task</DialogTitle>
<MarkdownEditor value="@Pap" onChange={() => {}} mentions={mentions} />
</DialogContent>
</Dialog>,
);
});
await flush();
const editable = document.body.querySelector('[data-testid="mdx-editor"]');
const textNode = editable?.firstChild;
expect(textNode?.nodeType).toBe(Node.TEXT_NODE);
const selection = window.getSelection();
const range = document.createRange();
range.setStart(textNode!, "@Pap".length);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
act(() => {
document.dispatchEvent(new Event("selectionchange"));
});
await flush();
const menu = document.body.querySelector('[data-testid="mention-autocomplete-menu"]');
expect(menu).toBeTruthy();
const wheel = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 80 });
act(() => {
menu?.dispatchEvent(wheel);
});
expect(wheel.defaultPrevented).toBe(false);
const touchMove = createTouchEvent("touchmove", [{ clientX: 100, clientY: 90 }]);
act(() => {
menu?.firstElementChild?.dispatchEvent(touchMove);
});
expect(touchMove.defaultPrevented).toBe(false);
await act(async () => {
root.unmount();
});
});
it("caps rendered mention matches while keeping the menu scrollable", async () => {
const handleChange = vi.fn();
const mentions = Array.from({ length: 60 }, (_, index) => ({

View File

@ -1392,13 +1392,23 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
<div
data-paperclip-floating-ui=""
data-testid="mention-autocomplete-menu"
className="pointer-events-auto fixed z-(--z-9999) min-w-(--sz-180px) max-w-(--sz-calc-15) max-h-(--sz-208px) overflow-y-auto rounded-md border border-border bg-popover shadow-md"
className="pointer-events-auto fixed z-(--z-9999) min-w-(--sz-180px) max-w-(--sz-calc-15) max-h-(--sz-208px) overflow-y-auto overscroll-contain rounded-md border border-border bg-popover shadow-md"
style={{
top: mentionMenuPosition.top,
left: mentionMenuPosition.left,
touchAction: "pan-y",
WebkitOverflowScrolling: "touch",
}}
onWheelCapture={(event) => {
// Modal scroll locks treat this body-level portal as outside the
// dialog. Keep wheel input on the menu so the lock cannot cancel it.
event.stopPropagation();
}}
onTouchMove={(event) => {
// Let the touched option observe movement first, then keep the
// native event from reaching a modal's document-level scroll lock.
event.stopPropagation();
}}
>
{filteredMentions.map((option, i) => (
<button