mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
co-authored by
Guderlilian
parent
f1ab15045f
commit
dbf56346e8
@@ -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' }))
|
||||
|
||||
+185
-2
@@ -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
|
||||
}
|
||||
+133
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user