diff --git a/desktop/src/shared/styles/globals/composer.css b/desktop/src/shared/styles/globals/composer.css index 0af4a59b4f..5df54e9ed4 100644 --- a/desktop/src/shared/styles/globals/composer.css +++ b/desktop/src/shared/styles/globals/composer.css @@ -99,6 +99,26 @@ line-height: var(--text-sm--line-height); } +/* Mention chips are inline decorations over live, editable text, so their box + is also a click target for caret placement. `--inline-chip-padding-inline` + (0.25rem in markdown.css) paints 4px of chip past the last glyph, and a click + in that band resolves to the position *before* the trailing space — inside + the handle. The user aims just after the name, the caret lands in it, and the + next keystroke turns "@bob " into "@bobx". + + Measured against `.mention-chip` in the composer: the trap band is exactly + the inline padding — 4.0px at the default, 0.0px here. Display mode is not + involved; `inline` and `inline-flex` hit-test identically. + + Zeroing the variable (rather than the padding) keeps the agent chip's + derived geometry consistent: its `padding-left` stays icon + gap and its + `::before` robot icon simply moves flush to the chip's left edge. Rendered + messages are untouched — they are not editable, so the band costs nothing + there and the padded pill is the intended look. */ +.rich-text-composer .tiptap { + --inline-chip-padding-inline: 0; +} + .rich-text-composer .tiptap p { margin: 0; } diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index ed00e8c355..ea247c93f7 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -606,6 +606,76 @@ test("selecting a person mention inserts @Name into input", async ({ await expect(mentionChip).not.toHaveClass(/agent-mention-highlight/); }); +test("clicking past the end of a mention chip puts the caret outside the handle", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("Hey @bo"); + await autocomplete(page).getByText("bob").click(); + await expect(input).toHaveText("Hey @bob "); + await page.keyboard.type("tail"); + await expect(input).toHaveText("Hey @bob tail"); + + const chip = input.locator(".mention-chip", { hasText: "@bob" }); + const geometry = await chip.evaluate((el) => { + const glyphs = document.createRange(); + glyphs.selectNodeContents(el.firstChild as Node); + const glyphRects = glyphs.getClientRects(); + const box = el.getBoundingClientRect(); + + // The space that follows the chip, measured on its own: the caret band + // that belongs to it is the only part of this line whose width is a font + // metric rather than a style we choose. + const after = el.nextSibling; + const space = document.createRange(); + space.setStart(after as Node, 0); + space.setEnd(after as Node, 1); + const spaceRect = space.getBoundingClientRect(); + + return { + band: box.right - glyphRects[glyphRects.length - 1].right, + spaceWidth: spaceRect.width, + // Three quarters into the space glyph — past its midpoint on any font, + // so this resolves to the position *after* the space everywhere. + probeX: spaceRect.left + spaceRect.width * 0.75, + y: box.y + box.height / 2, + }; + }); + + // This is the regression pin, and it is font-independent: the chip's painted + // box must not extend past its last glyph. Every pixel that is visually after + // the name but still inside the chip resolves to the position *before* the + // trailing space — inside the handle — so the user aims just after the name, + // the caret lands in it, and the next keystroke turns "@bob " into "@bobx". + expect(geometry.band).toBeLessThanOrEqual(0.5); + + // And the caret behaves: clicking in the space after the name types after the + // name. (A click nearer the glyph cannot be asserted portably — the trapping + // band that is left is half a space wide, and a space is not the same width + // on every platform.) + expect(geometry.spaceWidth).toBeGreaterThan(0); + await page.mouse.click(geometry.probeX, geometry.y); + const caretInChip = await page.evaluate(() => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return true; + const node = selection.getRangeAt(0).startContainer; + const element = + node.nodeType === Node.ELEMENT_NODE + ? (node as HTMLElement) + : node.parentElement; + return !!element?.closest(".mention-chip"); + }); + expect(caretInChip).toBe(false); + + await page.keyboard.type("Z"); + await expect(input).toHaveText("Hey @bob Ztail"); + await expect(chip).toHaveText("@bob"); +}); + test("selecting a managed agent mention inserts @Name into input", async ({ page, }) => {