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
This commit is contained in:
Pascal André 2026-07-26 18:51:30 +02:00 committed by GitHub
parent b81044ffa2
commit b1bb8a723a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 85 additions and 8 deletions

View file

@ -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

View file

@ -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() {

View file

@ -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 },
)
})

View file

@ -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),
}
}