From b1bb8a723af6fa4dbba2ca810f66b58b1f56a3ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 26 Jul 2026 18:51:30 +0200 Subject: [PATCH] fix(ui): avoid mobile selection action overlap (#622) ## Summary - place CodeNomad selection actions below the final selected line on touch-only devices - keep desktop and hybrid fine-pointer positioning unchanged - fall back to the message stream's top edge when bottom placement would be clipped ## Validation - 146 UI Node tests - npm run typecheck - npm run build --workspace @codenomad/ui - git diff --check Closes #598 --- .github/workflows/pr-build.yml | 1 + .../ui/src/components/message-section.tsx | 24 ++++++++---- .../lib/message-selection-position.test.ts | 29 ++++++++++++++ .../ui/src/lib/message-selection-position.ts | 39 +++++++++++++++++++ 4 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/lib/message-selection-position.test.ts create mode 100644 packages/ui/src/lib/message-selection-position.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index aa599e28..e6984143 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -106,6 +106,7 @@ jobs: node --import tsx --test packages/ui/src/components/session-list-visibility.test.ts packages/ui/src/lib/hooks/use-app-session-capture.test.ts + packages/ui/src/lib/message-selection-position.test.ts packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts packages/ui/src/stores/app-session-reconciliation.test.ts diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index ee34a83f..f8030233 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -21,6 +21,7 @@ import type { DeleteHoverState } from "../types/delete-hover" import { partHasRenderableText } from "../types/message" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { getPartCharCount } from "../lib/token-utils" +import { getMessageSelectionActionPosition } from "../lib/message-selection-position" import { buildSessionSearchMatches } from "../lib/session-search" import type { SessionSearchMatch } from "../lib/session-search" import { resolveThinkingExpansionDefault, resolveToolVisibility } from "./tool-call/tool-registry" @@ -998,15 +999,22 @@ export default function MessageSection(props: MessageSectionProps) { clearQuoteSelection() return } - const rects = range.getClientRects() - const anchorRect = rects.length > 0 ? rects[0] : range.getBoundingClientRect() + const rects = Array.from(range.getClientRects()) + const fallbackRect = range.getBoundingClientRect() const shellRect = shell.getBoundingClientRect() - const relativeTop = Math.max(anchorRect.top - shellRect.top - 40, 8) - // Keep the popover within the stream shell. The quote popover currently - // renders 3 actions; keep enough horizontal room for the pill. - const maxLeft = Math.max(shell.clientWidth - 260, 8) - const relativeLeft = Math.min(Math.max(anchorRect.left - shellRect.left, 8), maxLeft) - setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft }) + const touchOnly = Boolean( + window.matchMedia?.("(pointer: coarse)")?.matches + && !window.matchMedia?.("(any-pointer: fine)")?.matches, + ) + const position = getMessageSelectionActionPosition( + rects, + fallbackRect, + shellRect, + shell.clientWidth, + shell.clientHeight, + touchOnly, + ) + setQuoteSelection({ text: limited, ...position }) } function handleStreamMouseUp() { diff --git a/packages/ui/src/lib/message-selection-position.test.ts b/packages/ui/src/lib/message-selection-position.test.ts new file mode 100644 index 00000000..daacf637 --- /dev/null +++ b/packages/ui/src/lib/message-selection-position.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { getMessageSelectionActionPosition } from "./message-selection-position.ts" + +test("places touch selection actions below the final line while desktop stays above", () => { + const rects = [ + { top: 180, bottom: 200, left: 120 }, + { top: 220, bottom: 240, left: 80 }, + ] + const shell = { top: 100, left: 20 } + + assert.deepEqual(getMessageSelectionActionPosition(rects, rects[0]!, shell, 400, 300, false), { + top: 40, + left: 100, + }) + assert.deepEqual(getMessageSelectionActionPosition(rects, rects[0]!, shell, 400, 300, true), { + top: 172, + left: 60, + }) +}) + +test("keeps touch selection actions visible near the bottom edge", () => { + const rect = { top: 340, bottom: 360, left: 80 } + + assert.deepEqual( + getMessageSelectionActionPosition([rect], rect, { top: 100, left: 20 }, 400, 300, true), + { top: 8, left: 60 }, + ) +}) diff --git a/packages/ui/src/lib/message-selection-position.ts b/packages/ui/src/lib/message-selection-position.ts new file mode 100644 index 00000000..14bbeba9 --- /dev/null +++ b/packages/ui/src/lib/message-selection-position.ts @@ -0,0 +1,39 @@ +interface SelectionRect { + top: number + bottom: number + left: number +} + +interface SelectionShellRect { + top: number + left: number +} + +const EDGE_GAP_PX = 8 +const DESKTOP_OFFSET_PX = 40 +const MOBILE_HANDLE_GAP_PX = 32 +const POPOVER_HEIGHT_PX = 40 +const POPOVER_WIDTH_PX = 260 + +export function getMessageSelectionActionPosition( + selectionRects: readonly SelectionRect[], + fallbackRect: SelectionRect, + shellRect: SelectionShellRect, + shellWidth: number, + shellHeight: number, + placeBelowSelection: boolean, +): { top: number; left: number } { + const anchor = selectionRects.length + ? selectionRects[placeBelowSelection ? selectionRects.length - 1 : 0]! + : fallbackRect + const belowTop = anchor.bottom - shellRect.top + MOBILE_HANDLE_GAP_PX + const top = placeBelowSelection + ? belowTop <= shellHeight - POPOVER_HEIGHT_PX - EDGE_GAP_PX ? belowTop : EDGE_GAP_PX + : anchor.top - shellRect.top - DESKTOP_OFFSET_PX + const maxLeft = Math.max(shellWidth - POPOVER_WIDTH_PX, EDGE_GAP_PX) + + return { + top: Math.max(top, EDGE_GAP_PX), + left: Math.min(Math.max(anchor.left - shellRect.left, EDGE_GAP_PX), maxLeft), + } +}