Keep phone chat menus above the keyboard (#38834)

Slash and mention suggestion menus in the phone chat composer stay reachable above the on-screen keyboard while typing.

Co-authored-by: Guderlilian <73724370+qq2144@users.noreply.github.com>
This commit is contained in:
Brennan Benson
2026-11-06 15:05:05 -08:00
committed by GitHub
co-authored by Guderlilian
parent f1ab15045f
commit dbf56346e8
18 changed files with 1015 additions and 39 deletions
@@ -21,6 +21,14 @@ const COPIED_STYLES = [
'paddingLeft'
] as const
export function copyTextInputStyles(source: HTMLTextAreaElement, copy: HTMLElement): void {
const computed = getComputedStyle(source)
for (const property of COPIED_STYLES) {
copy.style[property] = computed[property]
}
copy.style.width = `${source.clientWidth}px`
}
// Measure a hidden copy: resizing the live textarea clamps scrolling ancestors in WebKit.
export function useTextInputContentHeight(
input: RefObject<TextInput | null>,
@@ -37,10 +45,7 @@ export function useTextInputContentHeight(
return
}
const computed = getComputedStyle(source)
for (const property of COPIED_STYLES) {
copy.style[property] = computed[property]
}
copy.style.width = `${source.clientWidth}px`
copyTextInputStyles(source, copy)
// A trailing newline only takes up a line once something follows it.
copy.value = text.current.endsWith('\n') ? `${text.current} ` : text.current
// The copy has no border; the field's own border sits outside its scrollHeight.
@@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import { act, useRef } from 'react'
import { createRoot } from 'react-dom/client'
import { TextInput } from 'react-native'
import type * as ReactNative from 'react-native'
import { expect, it, vi } from 'vitest'
import { useTextInputSelectionReveal } from './text-input-selection-reveal.web'
vi.mock('react-native', () => vi.importActual<typeof ReactNative>('react-native-web'))
it('reveals the current caret after the field is capped and preserves later manual scrolling', async () => {
const resized: (() => void)[] = []
class LayoutObserver implements ResizeObserver {
constructor(callback: ResizeObserverCallback) {
resized.push(() => callback([], this))
}
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', LayoutObserver)
let height = 60
let caretTop = 120
vi.spyOn(HTMLTextAreaElement.prototype, 'clientWidth', 'get').mockReturnValue(300)
vi.spyOn(HTMLTextAreaElement.prototype, 'clientHeight', 'get').mockImplementation(() => height)
vi.spyOn(HTMLSpanElement.prototype, 'getClientRects').mockImplementation(() => {
const rect = new DOMRect(0, caretTop, 1, 20)
return {
0: rect,
length: 1,
item: () => rect,
[Symbol.iterator]: function* (): Generator<DOMRect, undefined, unknown> {
yield rect
}
}
})
function Editor() {
const input = useRef<TextInput>(null)
useTextInputSelectionReveal(input)
return (
<TextInput ref={input} multiline defaultValue={'one\ntwo\nthree\nfour\nfive\nsix\nseven'} />
)
}
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
const paint = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
try {
await act(async () => root.render(<Editor />))
const field = container.querySelector('textarea')!
field.focus()
field.setSelectionRange(field.value.length, field.value.length)
await act(async () => {
height = 40
resized.forEach((resize) => resize())
await paint()
})
expect(field.scrollTop).toBe(100)
await act(async () => {
caretTop = 40
field.setSelectionRange(4, 4)
field.dispatchEvent(new Event('select'))
await paint()
})
expect(field.scrollTop).toBe(40)
expect(field.selectionStart).toBe(4)
field.scrollTop = 65
field.dispatchEvent(new Event('scroll'))
await act(paint)
expect(field.scrollTop).toBe(65)
expect(document.activeElement).toBe(field)
} finally {
await act(async () => root.unmount())
container.remove()
vi.restoreAllMocks()
vi.unstubAllGlobals()
}
})
@@ -0,0 +1,82 @@
import { useLayoutEffect, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import { copyTextInputStyles } from './text-input-content-height.web'
// A textarea exposes selection offsets, but no rectangle for its caret.
export function useTextInputSelectionReveal(input: RefObject<TextInput | null>): void {
useLayoutEffect(() => {
const field: unknown = input.current
if (!(field instanceof HTMLTextAreaElement)) {
return
}
const copy = document.createElement('div')
const caret = document.createElement('span')
copy.setAttribute('aria-hidden', 'true')
Object.assign(copy.style, {
position: 'fixed',
height: '0',
overflow: 'hidden',
top: '0',
left: '0',
visibility: 'hidden',
pointerEvents: 'none',
border: '0',
margin: '0',
boxSizing: 'border-box'
})
document.body.append(copy)
let frame: number | null = null
const reveal = () => {
frame = null
if (document.activeElement !== field || !field.isConnected || field.clientHeight <= 0) {
return
}
copyTextInputStyles(field, copy)
const selection =
field.selectionDirection === 'backward' ? field.selectionStart : field.selectionEnd
copy.textContent = field.value.slice(0, selection)
caret.textContent = field.value.slice(selection) || '\u200b'
copy.append(caret)
const rect = caret.getClientRects()[0]
if (!rect) {
return
}
const top = rect.top - copy.getBoundingClientRect().top
const bottom = top + rect.height
if (top < field.scrollTop) {
field.scrollTop = top
} else if (bottom > field.scrollTop + field.clientHeight) {
field.scrollTop = bottom - field.clientHeight
}
}
const schedule = () => {
if (frame !== null) {
cancelAnimationFrame(frame)
}
frame = requestAnimationFrame(reveal)
}
let width = field.clientWidth
let height = field.clientHeight
const observer = new ResizeObserver(() => {
if (width !== field.clientWidth || height !== field.clientHeight) {
width = field.clientWidth
height = field.clientHeight
schedule()
}
})
observer.observe(field)
field.addEventListener('select', schedule)
field.addEventListener('input', schedule)
field.addEventListener('compositionend', schedule)
return () => {
observer.disconnect()
field.removeEventListener('select', schedule)
field.removeEventListener('input', schedule)
field.removeEventListener('compositionend', schedule)
if (frame !== null) {
cancelAnimationFrame(frame)
}
copy.remove()
}
}, [input])
}
@@ -76,7 +76,7 @@ vi.mock('../native-chat/input/MobileNativeChatSessionOptionPickers', () => ({
MobileNativeChatSessionOptionPickers: 'SessionOptions'
}))
vi.mock('../native-chat/composer/MobileNativeChatComposerMenu', () => ({
MobileNativeChatComposerMenu: 'Suggestions'
MobileNativeChatComposerSuggestions: 'Suggestions'
}))
vi.mock('../../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('../../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' }))
@@ -78,7 +78,7 @@ vi.mock('./input/MobileNativeChatSessionOptionPickers', () => ({
MobileNativeChatSessionOptionPickers: 'SessionOptions'
}))
vi.mock('./composer/MobileNativeChatComposerMenu', () => ({
MobileNativeChatComposerMenu: 'Suggestions'
MobileNativeChatComposerSuggestions: 'Suggestions'
}))
vi.mock('../../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('../../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' }))
@@ -7,6 +7,10 @@ import { MobileNativeChatComposer } from './MobileNativeChatComposer'
import { MobileNativeChatLowerRegion } from '../MobileNativeChatLowerRegion.web'
vi.mock('react-native', () => vi.importActual<typeof ReactNative>('react-native-web'))
vi.mock(
'./MobileNativeChatComposerAllocation',
() => import('./MobileNativeChatComposerAllocation.web')
)
vi.mock('./MobileNativeChatComposerField', () => import('./MobileNativeChatComposerField.web'))
vi.mock('../../../platform/host-os', () => import('../../../platform/host-os.web'))
vi.mock('../../../platform/keyboard-occlusion', () => ({ useKeyboardOcclusion: () => 0 }))
@@ -16,6 +20,7 @@ vi.mock('lucide-react-native', () => ({
Image: () => null,
Package: () => null,
Paperclip: () => null,
FileText: () => null,
Terminal: () => null
}))
vi.mock('../../../components/ActionSheetModal', () => ({ ActionSheetModal: () => null }))
@@ -31,7 +36,10 @@ vi.mock('./MobileNativeChatEditActions', () => ({ MobileNativeChatEditActions: (
vi.mock('./MobileNativeChatComposerAttachments', () => ({
MobileNativeChatComposerAttachments: () => null
}))
vi.mock('./MobileNativeChatComposerMenu', () => ({ MobileNativeChatComposerMenu: () => null }))
vi.mock(
'./MobileNativeChatComposerMenuLayer',
() => import('./MobileNativeChatComposerMenuLayer.web')
)
type ComposerProps = ComponentProps<typeof MobileNativeChatComposer>
@@ -44,6 +52,7 @@ describe('mobile composer modifier submit in the paired web page', () => {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPad')
onSend.mockReset().mockResolvedValue(true)
container = document.createElement('div')
container.setAttribute('data-testid', 'native-chat-view')
document.body.append(container)
root = createRoot(container)
})
@@ -65,7 +74,7 @@ describe('mobile composer modifier submit in the paired web page', () => {
async function render(props: Partial<ComposerProps> = {}) {
function Draft() {
const [value, setValue] = useState(props.value ?? ' hello ')
return (
const composer = (
<MobileNativeChatComposer
sendSurfaceId="keyboard-test"
getSendCompletionGeneration={() => 0}
@@ -76,6 +85,13 @@ describe('mobile composer modifier submit in the paired web page', () => {
onSend={onSend}
/>
)
return props.editSession ? (
<div data-testid="queue-viewport" style={{ overflow: 'hidden' }}>
{composer}
</div>
) : (
composer
)
}
await act(async () => root.render(<Draft />))
await act(async () => field().focus())
@@ -226,4 +242,171 @@ describe('mobile composer modifier submit in the paired web page', () => {
expect(field().value).toBe(expected)
}
)
function queuedGeometry() {
const anchor = { top: 430, width: 390, clipTop: 250, clipHeight: 300 }
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLElement
) {
if (this === container) {
return new DOMRect(0, 80, 390, 620)
}
if (this.getAttribute('data-testid') === 'queue-viewport') {
return new DOMRect(0, anchor.clipTop, 390, anchor.clipHeight)
}
return new DOMRect(0, anchor.top, anchor.width, 100)
})
return anchor
}
async function queuedMention() {
await render({
value: '@rea',
filePaths: ['README.md', 'src/read.ts'],
agent: 'codex',
editSession: { cancel: vi.fn(), refuseCommand: () => false, locked: false }
})
await selectEnd()
}
function suggestion() {
return container.querySelector<HTMLElement>('[aria-label="README.md"]')
}
it('keeps suggestions with their focused composer, including keyboard focus inside its popup', async () => {
queuedGeometry()
let openQueue!: () => void
let typeQueuedTrigger!: () => void
const save = vi.fn().mockResolvedValue(true)
function Drafts() {
const [normal, setNormal] = useState('@rea')
const [queued, setQueued] = useState('')
const [editing, setEditing] = useState(false)
openQueue = () => setEditing(true)
typeQueuedTrigger = () => setQueued('@que')
return (
<>
<MobileNativeChatComposer
sendSurfaceId="normal-test"
getSendCompletionGeneration={() => 0}
getComposerEditGeneration={() => 0}
value={normal}
onChangeText={setNormal}
onSend={onSend}
filePaths={['README.md']}
/>
{editing ? (
<MobileNativeChatComposer
sendSurfaceId="queued-test"
getSendCompletionGeneration={() => 0}
getComposerEditGeneration={() => 0}
value={queued}
onChangeText={setQueued}
onSend={save}
filePaths={['queue.ts']}
editSession={{ cancel: vi.fn(), refuseCommand: () => false, locked: false }}
/>
) : null}
<button data-testid="outside-composers">Outside the composers</button>
</>
)
}
await act(async () => root.render(<Drafts />))
const normal = field()
await act(async () => normal.focus())
await selectEnd()
expect(suggestion()).not.toBeNull()
await act(async () => openQueue())
const queued = container.querySelectorAll('textarea')[1]!
await act(async () => queued.focus())
expect(suggestion()).toBeNull()
await act(async () => typeQueuedTrigger())
await act(async () => {
queued.setSelectionRange(4, 4)
document.dispatchEvent(new Event('selectionchange', { bubbles: true }))
queued.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight', bubbles: true }))
})
const row = container.querySelector<HTMLElement>('[aria-label="queue.ts"]')!
expect(row).not.toBeNull()
expect(suggestion()).toBeNull()
await act(async () => row.focus())
expect(document.activeElement).toBe(row)
expect(row.isConnected).toBe(true)
await act(async () => {
row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
row.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }))
// happy-dom does not synthesize the browser's native button click for Enter.
row.click()
})
expect(queued.value).toBe('@queue.ts ')
expect(normal.value).toBe('@rea')
expect(document.activeElement).toBe(queued)
expect(queued.selectionStart).toBe(10)
expect(onSend).not.toHaveBeenCalled()
expect(save).not.toHaveBeenCalled()
await act(async () => normal.focus())
expect(suggestion()).not.toBeNull()
await act(async () =>
container.querySelector<HTMLElement>('[data-testid="outside-composers"]')!.focus()
)
expect(suggestion()).toBeNull()
await act(async () => window.dispatchEvent(new Event('resize')))
expect(suggestion()).toBeNull()
expect(container.querySelector('[aria-label="queue.ts"]')).toBeNull()
})
it('picks a suggestion outside the clipped queue while keeping the edited draft focused', async () => {
queuedGeometry()
await queuedMention()
const input = field()
const row = suggestion()
expect(row).not.toBeNull()
const queue = container.querySelector('[data-testid="queue-viewport"]')!
expect(queue.contains(row)).toBe(false)
expect(container.contains(row)).toBe(true)
const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
await act(async () => {
row!.dispatchEvent(down)
row!.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
row!.click()
})
expect(down.defaultPrevented).toBe(true)
expect(field()).toBe(input)
expect(input.value).toBe('@README.md ')
expect(document.activeElement).toBe(input)
expect(suggestion()).toBeNull()
expect(onSend).not.toHaveBeenCalled()
})
it('retires scrolled-out or hidden editor suggestions and restores them from current geometry', async () => {
const anchor = queuedGeometry()
await queuedMention()
expect(
suggestion(),
JSON.stringify({
value: field().value,
caret: field().selectionStart,
focused: document.activeElement === field(),
viewport: window.innerHeight,
text: container.textContent?.slice(0, 120)
})
).not.toBeNull()
anchor.top = 900
await act(async () => window.dispatchEvent(new Event('scroll')))
expect(suggestion()).toBeNull()
anchor.top = 430
await act(async () => window.dispatchEvent(new Event('scroll')))
expect(suggestion()).not.toBeNull()
anchor.clipTop = 450
anchor.clipHeight = 0
await act(async () => window.dispatchEvent(new Event('resize')))
expect(suggestion()).toBeNull()
anchor.clipTop = 250
anchor.clipHeight = 300
anchor.width = 0
await act(async () => window.dispatchEvent(new Event('resize')))
expect(suggestion()).toBeNull()
expect(field().value).toBe('@rea')
expect(document.activeElement).toBe(field())
})
})
@@ -29,7 +29,9 @@ vi.mock('./MobileNativeChatEditActions', () => ({ MobileNativeChatEditActions: (
vi.mock('./MobileNativeChatComposerAttachments', () => ({
MobileNativeChatComposerAttachments: () => null
}))
vi.mock('./MobileNativeChatComposerMenu', () => ({ MobileNativeChatComposerMenu: () => null }))
vi.mock('./MobileNativeChatComposerMenu', () => ({
MobileNativeChatComposerSuggestions: () => null
}))
type ComposerProps = ComponentProps<typeof MobileNativeChatComposer>
@@ -10,14 +10,14 @@ import {
pruneMobileNativeChatDraftSkills
} from './mobile-native-chat-draft-inputs'
import type { NativeChatSkillDiscoverySnapshot } from '../../../../../src/shared/native-chat/input/native-chat-picker-items'
import { MobileNativeChatComposerAllocation } from './MobileNativeChatComposerAllocation'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useMobileNativeChatComposerRegion } from '../mobile-native-chat-lower-region-context'
import { Keyboard, type TextInput, View } from 'react-native'
import type { NativeChatContextUsageSummary } from '../../../../../src/shared/native-chat/native-chat-context-usage-summary'
import { colors } from '../../../theme/mobile-theme'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../../../i18n/use-mobile-translation'
import { MobileNativeChatComposerMenu } from './MobileNativeChatComposerMenu'
import { MobileNativeChatComposerSuggestions } from './MobileNativeChatComposerMenu'
import { MobileNativeChatComposerField } from './MobileNativeChatComposerField'
import { MobileNativeChatComposerAttachments } from './MobileNativeChatComposerAttachments'
import type { PendingNativeChatFile } from './mobile-native-chat-file-attachment'
@@ -32,7 +32,6 @@ import {
mobileNativeChatComposerPasteHandler,
type MobilePastedImages
} from './mobile-native-chat-composer-paste'
import type { MobileComposerStructuredCatalog } from './mobile-native-chat-composer-menu'
import { useMobileNativeChatComposerMenu } from './use-mobile-native-chat-composer-menu'
import { mobileNativeChatComposerShowsStop } from './mobile-native-chat-composer-layout'
import { MobileNativeChatStopButton } from '../input/MobileNativeChatStopButton'
@@ -45,7 +44,7 @@ import { useMobileNativeChatContextRing } from './MobileNativeChatContextUsageRi
import type { MobileNativeChatSendIntent } from '../mobile-native-chat-send'
import { MobileNativeChatComposerMic } from './MobileNativeChatComposerMic'
import type { MobileNativeChatMicProps } from './MobileNativeChatComposerMic'
import type { MobileStructuredThreadGoal } from '../../use-mobile-structured-thread-goal'
import type { MobileNativeChatStructuredComposer } from './mobile-native-chat-structured-composer'
import { useMobileNativeChatComposerGoalMode } from './use-mobile-native-chat-composer-goal-mode'
import { MobileNativeChatComposerPlanChip } from './MobileNativeChatComposerPlanChip'
@@ -53,19 +52,7 @@ const NO_FILE_PATHS: string[] = []
const NO_ATTACHMENTS: PendingNativeChatImage[] = []
const NO_FILES: PendingNativeChatFile[] = []
export type MobileNativeChatStructuredComposer = {
catalog: MobileComposerStructuredCatalog
onSlashMenuOpen: () => void
onSlashMenuDemand?: (open: boolean) => void
onAttachFiles?: () => void
/** Set when the host keeps phone files, so a long paste becomes a "Pasted text" file. */
onAttachPastedText?: (text: string) => void
// An uploading attachment must hold Send.
files: readonly PendingNativeChatFile[]
onRemoveFile: (id: string) => void
/** Null unless the host reports goals for this chat. */
threadGoal?: MobileStructuredThreadGoal | null
}
export type { MobileNativeChatStructuredComposer } from './mobile-native-chat-structured-composer'
type Props = MobileNativeChatMicProps & {
// Applies dictation's edit to the draft this composer shows; false when it can no longer take it.
@@ -159,6 +146,7 @@ export function MobileNativeChatComposer({
const t = useMobileTranslation()
const fallbackInputRef = useRef<TextInput>(null)
const fieldRef = inputRef ?? fallbackInputRef
const groupRef = useRef<View>(null)
const [focused, setFocused] = useState(false)
const [picker, setPicker] = useState<MobileNativeChatPickerPresentation | null>(null)
const request = sessionOptions?.controller.optionPickerRequest
@@ -296,17 +284,14 @@ export function MobileNativeChatComposer({
}
}
const groupRef = useMobileNativeChatComposerRegion(menu.rows.length > 0, focused)
return (
const content = (
<View ref={groupRef} testID="native-chat-composer-group">
{menu.rows.length > 0 ? (
<MobileNativeChatComposerMenu
rows={menu.rows}
onPick={menu.pick}
nestedScrollEnabled={editSession ? true : undefined}
/>
) : null}
<MobileNativeChatComposerSuggestions
anchorRef={groupRef}
rows={menu.rows}
onPick={menu.pick}
nestedScrollEnabled={editSession ? true : undefined}
/>
<View style={styles.composerInset} testID="native-chat-composer-inset">
{goal.strip}
<View {...contextRing.barProps} style={styles.bar} testID="native-chat-composer">
@@ -424,6 +409,15 @@ export function MobileNativeChatComposer({
</View>
</View>
)
return (
<MobileNativeChatComposerAllocation
anchorRef={groupRef}
fieldRef={fieldRef}
hasMenu={menu.rows.length > 0}
>
{content}
</MobileNativeChatComposerAllocation>
)
}
export function MobileNativeChatDraftComposer({
@@ -0,0 +1,30 @@
import type { ReactNode, RefObject } from 'react'
import type { TextInput, View } from 'react-native'
import type { ComposerMenuIntrinsicSize } from './mobile-native-chat-composer-menu-size'
export type ComposerAllocationProps = {
anchorRef: RefObject<View | null>
fieldRef: RefObject<TextInput | null>
hasMenu: boolean
children: ReactNode
}
export function MobileNativeChatComposerAllocation({ children }: ComposerAllocationProps) {
return children
}
export function MobileNativeChatComposerMenuReservation() {
return null
}
export type ComposerAllocation = {
reserveRef: RefObject<View | null>
reservation: number
fieldMaxHeight: number | undefined
onActiveChange: (active: boolean) => void
onIntrinsic: (size: ComposerMenuIntrinsicSize) => void
}
export function useMobileNativeChatComposerAllocation(): ComposerAllocation | null {
return null
}
@@ -0,0 +1,133 @@
// @vitest-environment happy-dom
import { act, useRef } from 'react'
import { createRoot } from 'react-dom/client'
import type * as ReactNative from 'react-native'
import { View, TextInput } from 'react-native'
import { expect, it, vi } from 'vitest'
import { MobileNativeChatLowerRegion } from '../MobileNativeChatLowerRegion.web'
import {
MobileNativeChatComposerAllocation,
useMobileNativeChatComposerAllocation
} from './MobileNativeChatComposerAllocation.web'
import {
MobileNativeChatComposerSuggestions,
type ComposerMenuRow
} from './MobileNativeChatComposerMenu'
vi.mock('react-native', () => vi.importActual<typeof ReactNative>('react-native-web'))
vi.mock(
'./MobileNativeChatComposerAllocation',
() => import('./MobileNativeChatComposerAllocation.web')
)
vi.mock(
'./MobileNativeChatComposerMenuLayer',
() => import('./MobileNativeChatComposerMenuLayer.web')
)
vi.mock('../../../platform/keyboard-occlusion', () => ({ useKeyboardOcclusion: () => 0 }))
vi.mock('lucide-react-native', () => ({
FileText: () => null,
Package: () => null,
Terminal: () => null
}))
const paint = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
for (const population of [10, 100]) {
it(`does no suggestion population work when capacity changes with ${population} results`, async () => {
let capacity = 600
let visits = 0
let fieldMaximum: number | undefined
const rows: ComposerMenuRow[] = Array.from({ length: population }, (_, index) => ({
kind: 'file',
id: `file-${index}`,
get path() {
visits++
return `file-${index}.ts`
}
}))
const container = document.createElement('div')
container.dataset.testid = 'native-chat-view'
document.body.append(container)
const root = createRoot(container)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLElement
) {
return new DOMRect(0, this === container ? 0 : 100, 300, this === container ? capacity : 100)
})
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => capacity)
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(300)
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (
this: HTMLElement
) {
if (this.dataset.testid === 'native-chat-composer-menu-reservation') {
return Number.parseFloat(this.style.height) || 0
}
if (this.dataset.testid === 'native-chat-composer-group') {
const reserve = this.querySelector<HTMLElement>(
'[data-testid="native-chat-composer-menu-reservation"]'
)
return 160 + (Number.parseFloat(reserve?.style.height ?? '') || 0)
}
return this instanceof HTMLTextAreaElement ? 80 : 100
})
function FieldBudget() {
fieldMaximum = useMobileNativeChatComposerAllocation()?.fieldMaxHeight
return null
}
function Composer({ items }: { items: ComposerMenuRow[] }) {
const anchor = useRef<View>(null)
const field = useRef<TextInput>(null)
return (
<MobileNativeChatComposerAllocation anchorRef={anchor} fieldRef={field} hasMenu>
<View ref={anchor} testID="native-chat-composer-group">
<MobileNativeChatComposerSuggestions
anchorRef={anchor}
rows={items}
onPick={() => {}}
/>
<TextInput ref={field} multiline defaultValue="@file" />
<FieldBudget />
</View>
</MobileNativeChatComposerAllocation>
)
}
const render = (items: ComposerMenuRow[]) => (
<MobileNativeChatLowerRegion>
<Composer items={items} />
</MobileNativeChatLowerRegion>
)
try {
await act(async () => root.render(render(rows)))
await act(async () => container.querySelector('textarea')!.focus())
await act(paint)
expect(
container.querySelector('[aria-label="file-0.ts"]'),
JSON.stringify({
focus: document.activeElement?.tagName,
fieldMaximum,
html: container.innerHTML.slice(-1800)
})
).not.toBeNull()
const prior = fieldMaximum
expect(prior).toBeGreaterThan(0)
visits = 0
await act(async () => {
capacity = 420
window.dispatchEvent(new Event('resize'))
await paint()
})
await act(paint)
expect(fieldMaximum).toBeLessThan(prior!)
expect(visits).toBe(0)
await act(async () =>
root.render(render([...rows, { kind: 'file', id: 'new', path: 'new-file.ts' }]))
)
expect(visits).toBeGreaterThan(0)
expect(container.querySelector('[aria-label="new-file.ts"]')).not.toBeNull()
} finally {
await act(async () => root.unmount())
container.remove()
vi.restoreAllMocks()
}
})
}
@@ -0,0 +1,108 @@
import {
createContext,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react'
import { View } from 'react-native'
import {
useMobileNativeChatLowerGeometry,
useMobileNativeChatLowerRegion
} from '../mobile-native-chat-lower-region-context'
import {
composerFieldAllowance,
composerMenuReservation,
type ComposerMenuIntrinsicSize
} from './mobile-native-chat-composer-menu-size'
import type {
ComposerAllocation,
ComposerAllocationProps
} from './MobileNativeChatComposerAllocation'
const AllocationContext = createContext<ComposerAllocation | null>(null)
export function useMobileNativeChatComposerAllocation() {
return useContext(AllocationContext)
}
export function MobileNativeChatComposerAllocation({
anchorRef,
fieldRef,
hasMenu,
children
}: ComposerAllocationProps) {
const { allocationHeight } = useMobileNativeChatLowerGeometry()
const lower = useMobileNativeChatLowerRegion()
const reserveRef = useRef<View>(null)
const menuWasShown = useRef(false)
const [active, setActive] = useState(false)
const [intrinsic, setIntrinsic] = useState<ComposerMenuIntrinsicSize | null>(null)
const [chrome, setChrome] = useState(0)
const onIntrinsic = useCallback((next: ComposerMenuIntrinsicSize) => {
setIntrinsic((current) =>
current?.content === next.content &&
current.chrome === next.chrome &&
current.firstGroup === next.firstGroup
? current
: next
)
}, [])
useLayoutEffect(() => {
const anchor: unknown = anchorRef.current
const field: unknown = fieldRef.current
if (!(anchor instanceof HTMLElement) || !(field instanceof HTMLElement)) {
return
}
const group = anchor.closest<HTMLElement>('[data-testid="queued-card-editor"]') ?? anchor
const measure = () => {
const reserved: unknown = reserveRef.current
const reservedHeight = reserved instanceof HTMLElement ? reserved.offsetHeight : 0
if (group.offsetHeight > 0 && field.offsetHeight > 0) {
setChrome(Math.max(0, group.offsetHeight - reservedHeight - field.offsetHeight))
}
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(group)
observer.observe(field)
return () => observer.disconnect()
}, [anchorRef, fieldRef])
const measured = lower && allocationHeight !== null
const reservation =
active && hasMenu && measured ? composerMenuReservation(intrinsic, allocationHeight, chrome) : 0
const fieldMaxHeight = measured
? composerFieldAllowance(allocationHeight, chrome, reservation)
: undefined
useLayoutEffect(() => {
lower?.layoutChanged()
const anchor: unknown = anchorRef.current
if (
hasMenu !== menuWasShown.current &&
anchor instanceof HTMLElement &&
anchor.contains(anchor.ownerDocument.activeElement)
) {
lower?.reveal(anchor, hasMenu ? 'menu' : 'edit')
}
menuWasShown.current = hasMenu
})
const value = useMemo(
() => ({ reserveRef, reservation, fieldMaxHeight, onActiveChange: setActive, onIntrinsic }),
[reservation, fieldMaxHeight, onIntrinsic]
)
return <AllocationContext value={value}>{children}</AllocationContext>
}
export function MobileNativeChatComposerMenuReservation() {
const allocation = useMobileNativeChatComposerAllocation()
return allocation ? (
<View
ref={allocation.reserveRef}
pointerEvents="none"
testID="native-chat-composer-menu-reservation"
style={{ height: allocation.reservation, flexShrink: 0 }}
/>
) : null
}
@@ -1,8 +1,10 @@
import { useMobileNativeChatComposerAllocation } from './MobileNativeChatComposerAllocation'
import { useEffect, useEffectEvent, useRef, useState } from 'react'
import { StyleSheet, TextInput, View, useWindowDimensions } from 'react-native'
import { useKeyboardOcclusion } from '../../../platform/keyboard-occlusion'
import { hostOs } from '../../../platform/host-os'
import { useTextInputContentHeight } from '../../../platform/text-input-content-height'
import { useTextInputSelectionReveal } from '../../../platform/text-input-selection-reveal.web'
import { colors } from '../../../theme/mobile-theme'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
import {
@@ -45,10 +47,12 @@ export function MobileNativeChatComposerField({
...props
}: MobileNativeChatComposerFieldProps) {
useMobileColorScheme()
const allocation = useMobileNativeChatComposerAllocation()
const [scrollTop, setScrollTop] = useState(0)
const [fieldHeight, setFieldHeight] = useState(0)
const ownRef = useRef<TextInput>(null)
const input = ref ?? ownRef
useTextInputSelectionReveal(input)
const paste = useEffectEvent((event: ClipboardEvent) => onWebPaste?.(event))
// react-native-web forwards no paste prop; its TextInput ref is the textarea itself.
useEffect(() => {
@@ -70,9 +74,10 @@ export function MobileNativeChatComposerField({
const keyboardHeight = useKeyboardOcclusion()
// RN Web already shrinks browser dimensions; shell WebViews report the keyboard separately.
const visibleHeight = Math.min(windowHeight, window.innerHeight - keyboardHeight)
const shareMaxHeight = Math.max(MESSAGE_MIN_HEIGHT, Math.round(visibleHeight * MAX_HEIGHT_SHARE))
const sizing = {
height: Math.max(contentHeight ?? 0, placeholderHeight ?? 0) || undefined,
maxHeight: Math.max(MESSAGE_MIN_HEIGHT, Math.round(visibleHeight * MAX_HEIGHT_SHARE))
maxHeight: Math.min(shareMaxHeight, allocation?.fieldMaxHeight ?? shareMaxHeight)
}
const copied = Boolean(skillToken || dictationPreview)
// Keep one tree so adding/removing a skill or dictation cannot remount the field and drop focus.
@@ -1,14 +1,39 @@
import { MobileNativeChatComposerMenuReservation } from './MobileNativeChatComposerAllocation'
import type { RefObject } from 'react'
import { MobileNativeChatComposerMenuLayer } from './MobileNativeChatComposerMenuLayer'
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { FileText, Package, Terminal } from 'lucide-react-native'
import type { NativeChatPickerItem } from '../../../../../src/shared/native-chat/input/native-chat-picker-items'
import { colors, radii, spacing, typography } from '../../../theme/mobile-theme'
import { themedStyleSheet } from '../../../theme/themed-styles'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
import { COMPOSER_MENU_MAX_HEIGHT } from './mobile-native-chat-composer-menu-size'
export type ComposerMenuRow = NativeChatPickerItem | { kind: 'file'; id: string; path: string }
const HEADINGS = { command: 'Commands', skill: 'Skills', file: 'Files' } as const
export function MobileNativeChatComposerSuggestions({
anchorRef,
...props
}: {
anchorRef: RefObject<View | null>
rows: readonly ComposerMenuRow[]
onPick: (row: ComposerMenuRow) => void
nestedScrollEnabled?: boolean
}) {
return (
<>
<MobileNativeChatComposerMenuReservation />
{props.rows.length > 0 ? (
<MobileNativeChatComposerMenuLayer anchorRef={anchorRef}>
<MobileNativeChatComposerMenu {...props} />
</MobileNativeChatComposerMenuLayer>
) : null}
</>
)
}
export function MobileNativeChatComposerMenu({
rows,
onPick,
@@ -20,7 +45,7 @@ export function MobileNativeChatComposerMenu({
}): React.JSX.Element {
useMobileColorScheme()
return (
<View style={styles.card}>
<View testID="native-chat-composer-menu-card" style={styles.card}>
<ScrollView
keyboardShouldPersistTaps="always"
nestedScrollEnabled={nestedScrollEnabled}
@@ -89,6 +114,7 @@ function MenuRow({
const styles = themedStyleSheet(() => ({
card: {
flexShrink: 1,
marginHorizontal: 10,
marginBottom: spacing.sm,
paddingVertical: 6,
@@ -99,7 +125,7 @@ const styles = themedStyleSheet(() => ({
overflow: 'hidden'
},
scroll: {
maxHeight: 300
maxHeight: COMPOSER_MENU_MAX_HEIGHT
},
heading: {
color: colors.textMuted,
@@ -0,0 +1,11 @@
import type { ReactNode, RefObject } from 'react'
import type { View } from 'react-native'
export type ComposerMenuLayerProps = {
anchorRef: RefObject<View | null>
children: ReactNode
}
export function MobileNativeChatComposerMenuLayer({ children }: ComposerMenuLayerProps) {
return children
}
@@ -0,0 +1,262 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { View } from 'react-native'
import { useKeyboardOcclusion } from '../../../platform/keyboard-occlusion'
import { useKeyboardPersistingTaps } from '../../../platform/keyboard-persisting-taps'
import { useMobileNativeChatLowerRegion } from '../mobile-native-chat-lower-region-context'
import { useMobileNativeChatComposerAllocation } from './MobileNativeChatComposerAllocation'
import type { ComposerMenuLayerProps } from './MobileNativeChatComposerMenuLayer'
type Placement = {
root: HTMLElement
left: number
bottom: number
width: number
height: number
reservedHeight?: number
offset: number
}
export function MobileNativeChatComposerMenuLayer({ anchorRef, children }: ComposerMenuLayerProps) {
const allocation = useMobileNativeChatComposerAllocation()
const reserveRef = allocation?.reserveRef
const onActiveChange = allocation?.onActiveChange
const onIntrinsic = allocation?.onIntrinsic
const keyboard = useKeyboardOcclusion()
const lower = useMobileNativeChatLowerRegion()
const keepKeyboard = useKeyboardPersistingTaps('always')
const surfaceRef = useRef<HTMLElement | null>(null)
const updateRef = useRef<(() => void) | null>(null)
const setSurface = useCallback(
(node: unknown) => {
surfaceRef.current = node instanceof HTMLElement ? node : null
keepKeyboard(node)
},
[keepKeyboard]
)
const [placement, setPlacement] = useState<Placement | null>(null)
const measureIntrinsic = useCallback(() => {
const card = surfaceRef.current?.querySelector<HTMLElement>(
'[data-testid="native-chat-composer-menu-card"]'
)
const scroll = card?.firstElementChild
const content = scroll?.firstElementChild
const first = content?.firstElementChild
if (!card || !(content instanceof HTMLElement) || !(first instanceof HTMLElement)) {
return
}
const style = getComputedStyle(card)
const chrome =
(Number.parseFloat(style.paddingTop) || 0) +
(Number.parseFloat(style.paddingBottom) || 0) +
(Number.parseFloat(style.borderTopWidth) || 0) +
(Number.parseFloat(style.borderBottomWidth) || 0) +
(Number.parseFloat(style.marginBottom) || 0)
onIntrinsic?.({ content: content.offsetHeight, chrome, firstGroup: first.offsetHeight })
}, [onIntrinsic])
useEffect(() => {
const anchor: unknown = anchorRef.current
if (!(anchor instanceof HTMLElement)) {
return
}
const chat = anchor.closest<HTMLElement>('[data-testid="native-chat-view"]')
if (!chat) {
return
}
const document = anchor.ownerDocument
let disposed = false
const place = (next: Placement | null) =>
setPlacement((current) =>
current === next ||
(current &&
next &&
current.root === next.root &&
current.left === next.left &&
current.bottom === next.bottom &&
current.width === next.width &&
current.height === next.height &&
current.reservedHeight === next.reservedHeight &&
current.offset === next.offset)
? current
: next
)
const update = (target: EventTarget | null = document.activeElement) => {
const moving = lower?.getContent()
const host = lower ? (moving instanceof HTMLElement ? moving : null) : chat
if (!host) {
place(null)
return
}
const ownFocus =
target instanceof Node && (anchor.contains(target) || surfaceRef.current?.contains(target))
const anchorRect = anchor.getBoundingClientRect()
const active = Boolean(
ownFocus && anchor.isConnected && anchorRect.width > 0 && anchorRect.height > 0
)
onActiveChange?.(active)
if (!active) {
place(null)
return
}
const reserve: unknown = reserveRef?.current
const reserved = reserve instanceof HTMLElement && reserve.offsetHeight > 0 ? reserve : null
const rect = (reserved ?? anchor).getBoundingClientRect()
const bounds = chat.getBoundingClientRect()
const hostBounds = host.getBoundingClientRect()
const page = window.visualViewport
let top = Math.max(bounds.top, page?.offsetTop ?? 0)
let bottom = Math.min(
bounds.bottom,
window.innerHeight - keyboard,
page ? page.offsetTop + page.height : window.innerHeight
)
const actualViewport = lower?.getViewport()
if (actualViewport instanceof HTMLElement) {
const visible = actualViewport.getBoundingClientRect()
top = Math.max(top, visible.top)
bottom = Math.min(bottom, visible.bottom)
}
let visibleTop = top
let visibleBottom = bottom
for (
let parent = (reserved ?? anchor).parentElement;
parent && parent !== chat;
parent = parent.parentElement
) {
const style = getComputedStyle(parent)
if (/(auto|scroll|hidden|clip)/.test(style.overflowY || style.overflow)) {
const clip = parent.getBoundingClientRect()
visibleTop = Math.max(visibleTop, clip.top)
visibleBottom = Math.min(visibleBottom, clip.bottom)
}
}
if (
rect.width <= 0 ||
rect.height <= 0 ||
visibleBottom <= visibleTop ||
rect.bottom <= visibleTop ||
rect.top >= visibleBottom
) {
place(null)
return
}
if (reserved) {
const paintTop = Math.max(rect.top, visibleTop)
const paintBottom = Math.min(rect.bottom, visibleBottom)
place(
paintBottom <= paintTop
? null
: {
root: host,
left: rect.left - hostBounds.left,
bottom: hostBounds.bottom - paintBottom,
width: rect.width,
height: paintBottom - paintTop,
reservedHeight: rect.height,
offset: rect.top - paintTop
}
)
} else {
const menuBottom = Math.max(rect.top, visibleTop)
place({
root: host,
left: rect.left - hostBounds.left,
bottom: hostBounds.bottom - menuBottom,
width: rect.width,
height: Math.max(0, menuBottom - top),
offset: 0
})
}
}
const geometry = () => update()
const onFocusOut = (event: FocusEvent) => {
if (event.relatedTarget) {
update(event.relatedTarget)
} else {
queueMicrotask(() => {
if (!disposed) {
update()
}
})
}
}
updateRef.current = geometry
update()
const observer = new ResizeObserver(geometry)
for (let parent: HTMLElement | null = anchor; parent; parent = parent.parentElement) {
observer.observe(parent)
}
document.addEventListener('focusin', geometry)
document.addEventListener('focusout', onFocusOut)
window.addEventListener('resize', geometry)
window.addEventListener('scroll', geometry, true)
window.visualViewport?.addEventListener('resize', geometry)
window.visualViewport?.addEventListener('scroll', geometry)
return () => {
disposed = true
updateRef.current = null
observer.disconnect()
document.removeEventListener('focusin', geometry)
document.removeEventListener('focusout', onFocusOut)
window.removeEventListener('resize', geometry)
window.removeEventListener('scroll', geometry, true)
window.visualViewport?.removeEventListener('resize', geometry)
window.visualViewport?.removeEventListener('scroll', geometry)
}
}, [anchorRef, reserveRef, onActiveChange, keyboard, lower?.getContent, lower?.getViewport])
useLayoutEffect(() => () => onActiveChange?.(false), [onActiveChange])
useLayoutEffect(() => {
updateRef.current?.()
measureIntrinsic()
})
useLayoutEffect(() => {
const surface = surfaceRef.current
const card = surface?.querySelector('[data-testid="native-chat-composer-menu-card"]')
const content = card?.firstElementChild?.firstElementChild
if (!card || !content) {
return
}
const observer = new ResizeObserver(measureIntrinsic)
observer.observe(card)
observer.observe(content)
return () => observer.disconnect()
}, [placement?.root, measureIntrinsic])
return placement
? createPortal(
<View
ref={setSurface}
pointerEvents="box-none"
style={{
position: 'absolute',
left: placement.left,
bottom: placement.bottom,
width: placement.width,
maxHeight: placement.height,
height: placement.reservedHeight === undefined ? undefined : placement.height,
overflow: 'hidden',
zIndex: 1
}}
>
<View
pointerEvents="box-none"
style={
placement.reservedHeight === undefined
? { flexShrink: 1, minHeight: 0 }
: {
position: 'absolute',
left: 0,
right: 0,
top: placement.offset,
height: placement.reservedHeight
}
}
>
{children}
</View>
</View>,
placement.root
)
: null
}
@@ -0,0 +1,32 @@
import { MESSAGE_MIN_HEIGHT } from '../../../components/composer-input/composer-input-styles'
export const COMPOSER_MENU_MAX_HEIGHT = 300
export type ComposerMenuIntrinsicSize = {
content: number
chrome: number
firstGroup: number
}
export function composerMenuReservation(
intrinsic: ComposerMenuIntrinsicSize | null,
viewport: number,
composerChrome: number
): number {
if (!intrinsic) {
return 0
}
const requested = Math.min(intrinsic.content, COMPOSER_MENU_MAX_HEIGHT) + intrinsic.chrome
const available = viewport - composerChrome - MESSAGE_MIN_HEIGHT
const firstGroup = Math.min(requested, intrinsic.firstGroup + intrinsic.chrome)
// When one result cannot fit beside the field, scroll the whole interaction instead.
return available >= firstGroup ? Math.min(requested, available) : requested
}
export function composerFieldAllowance(
viewport: number,
composerChrome: number,
reservation: number
): number {
return Math.max(MESSAGE_MIN_HEIGHT, Math.floor(viewport - composerChrome - reservation))
}
@@ -0,0 +1,17 @@
import type { MobileComposerStructuredCatalog } from './mobile-native-chat-composer-menu'
import type { PendingNativeChatFile } from './mobile-native-chat-file-attachment'
import type { MobileStructuredThreadGoal } from '../../use-mobile-structured-thread-goal'
export type MobileNativeChatStructuredComposer = {
catalog: MobileComposerStructuredCatalog
onSlashMenuOpen: () => void
onSlashMenuDemand?: (open: boolean) => void
onAttachFiles?: () => void
/** Set when the host keeps phone files, so a long paste becomes a "Pasted text" file. */
onAttachPastedText?: (text: string) => void
// An uploading attachment must hold Send.
files: readonly PendingNativeChatFile[]
onRemoveFile: (id: string) => void
/** Null unless the host reports goals for this chat. */
threadGoal?: MobileStructuredThreadGoal | null
}
+8
View File
@@ -1,6 +1,14 @@
{
"$comment": "Every .web.* sibling the Route A web build resolves ahead of its native file. One entry per documented React Native Web gap; config/scripts/mobile-web-app-web-overrides.test.mjs fails on an unlisted one, a listed file that is gone, or one with no native sibling.",
"overrides": [
{
"file": "src/session/native-chat/composer/MobileNativeChatComposerAllocation.web.tsx",
"reason": "The shipping host page measures the active editor's real chrome and menu reservation against the lower chat allocation, preserving native field sizing outside that DOM layout. Geometry consumers stay isolated from menu and queue rows."
},
{
"file": "src/session/native-chat/composer/MobileNativeChatComposerMenuLayer.web.tsx",
"reason": "The phone session renders in the host-served page. Suggestions must escape the queued editor’s scrolling clip while staying inside the chat’s stacking and visibility ancestry. DOM geometry and ancestor scroll/resize events anchor that portal; the native fallback retains its existing inline rendering."
},
{
"file": "src/transport/client-context.web.tsx",
"reason": "The page has no websocket transport and no pairing keychain. This is the single transport substitution point: it serves the BridgeRpcClient the entry built, for every hostId, because the bridge protocol names no host. The entry mounts nothing until init lands, so this provider never holds a client that cannot answer."