mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-07 07:23:34 +00:00
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:
parent
b81044ffa2
commit
b1bb8a723a
4 changed files with 85 additions and 8 deletions
1
.github/workflows/pr-build.yml
vendored
1
.github/workflows/pr-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
29
packages/ui/src/lib/message-selection-position.test.ts
Normal file
29
packages/ui/src/lib/message-selection-position.test.ts
Normal 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 },
|
||||
)
|
||||
})
|
||||
39
packages/ui/src/lib/message-selection-position.ts
Normal file
39
packages/ui/src/lib/message-selection-position.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue