From dbf56346e89b74aaaf3240172951fe505bc04366 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:05:05 -0800 Subject: [PATCH] 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> --- .../platform/text-input-content-height.web.ts | 13 +- .../text-input-selection-reveal.web.test.tsx | 78 ++++++ .../text-input-selection-reveal.web.ts | 82 ++++++ .../__mocks__/mobile-prompt-render-mocks.ts | 2 +- ...NativeChatView.prompt-presentation.test.ts | 2 +- ...leNativeChatComposer.keyboard.web.test.tsx | 187 ++++++++++++- ...obileNativeChatComposer.paste.web.test.tsx | 4 +- .../composer/MobileNativeChatComposer.tsx | 48 ++-- .../MobileNativeChatComposerAllocation.tsx | 30 ++ ...eNativeChatComposerAllocation.web.test.tsx | 133 +++++++++ ...MobileNativeChatComposerAllocation.web.tsx | 108 ++++++++ .../MobileNativeChatComposerField.web.tsx | 7 +- .../composer/MobileNativeChatComposerMenu.tsx | 30 +- .../MobileNativeChatComposerMenuLayer.tsx | 11 + .../MobileNativeChatComposerMenuLayer.web.tsx | 262 ++++++++++++++++++ .../mobile-native-chat-composer-menu-size.ts | 32 +++ .../mobile-native-chat-structured-composer.ts | 17 ++ mobile/web-entry/web-overrides.json | 8 + 18 files changed, 1015 insertions(+), 39 deletions(-) create mode 100644 mobile/src/platform/text-input-selection-reveal.web.test.tsx create mode 100644 mobile/src/platform/text-input-selection-reveal.web.ts create mode 100644 mobile/src/session/native-chat/composer/MobileNativeChatComposerAllocation.tsx create mode 100644 mobile/src/session/native-chat/composer/MobileNativeChatComposerAllocation.web.test.tsx create mode 100644 mobile/src/session/native-chat/composer/MobileNativeChatComposerAllocation.web.tsx create mode 100644 mobile/src/session/native-chat/composer/MobileNativeChatComposerMenuLayer.tsx create mode 100644 mobile/src/session/native-chat/composer/MobileNativeChatComposerMenuLayer.web.tsx create mode 100644 mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu-size.ts create mode 100644 mobile/src/session/native-chat/composer/mobile-native-chat-structured-composer.ts diff --git a/mobile/src/platform/text-input-content-height.web.ts b/mobile/src/platform/text-input-content-height.web.ts index 0c54ed94693..06cb4834f4d 100644 --- a/mobile/src/platform/text-input-content-height.web.ts +++ b/mobile/src/platform/text-input-content-height.web.ts @@ -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, @@ -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. diff --git a/mobile/src/platform/text-input-selection-reveal.web.test.tsx b/mobile/src/platform/text-input-selection-reveal.web.test.tsx new file mode 100644 index 00000000000..b59e749295e --- /dev/null +++ b/mobile/src/platform/text-input-selection-reveal.web.test.tsx @@ -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('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 { + yield rect + } + } + }) + function Editor() { + const input = useRef(null) + useTextInputSelectionReveal(input) + return ( + + ) + } + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + const paint = () => new Promise((resolve) => requestAnimationFrame(() => resolve())) + try { + await act(async () => root.render()) + 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() + } +}) diff --git a/mobile/src/platform/text-input-selection-reveal.web.ts b/mobile/src/platform/text-input-selection-reveal.web.ts new file mode 100644 index 00000000000..9dc4ae82f66 --- /dev/null +++ b/mobile/src/platform/text-input-selection-reveal.web.ts @@ -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): 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]) +} diff --git a/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts b/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts index 57b583a2881..14bef64ed1d 100644 --- a/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts +++ b/mobile/src/session/__mocks__/mobile-prompt-render-mocks.ts @@ -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' })) diff --git a/mobile/src/session/native-chat/MobileNativeChatView.prompt-presentation.test.ts b/mobile/src/session/native-chat/MobileNativeChatView.prompt-presentation.test.ts index b74a93bae9f..e905f481014 100644 --- a/mobile/src/session/native-chat/MobileNativeChatView.prompt-presentation.test.ts +++ b/mobile/src/session/native-chat/MobileNativeChatView.prompt-presentation.test.ts @@ -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' })) diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.keyboard.web.test.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.keyboard.web.test.tsx index 5537bda9a65..843b8369bb0 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.keyboard.web.test.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.keyboard.web.test.tsx @@ -7,6 +7,10 @@ import { MobileNativeChatComposer } from './MobileNativeChatComposer' import { MobileNativeChatLowerRegion } from '../MobileNativeChatLowerRegion.web' vi.mock('react-native', () => vi.importActual('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 @@ -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 = {}) { function Draft() { const [value, setValue] = useState(props.value ?? ' hello ') - return ( + const composer = ( 0} @@ -76,6 +85,13 @@ describe('mobile composer modifier submit in the paired web page', () => { onSend={onSend} /> ) + return props.editSession ? ( +
+ {composer} +
+ ) : ( + composer + ) } await act(async () => root.render()) 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('[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 ( + <> + 0} + getComposerEditGeneration={() => 0} + value={normal} + onChangeText={setNormal} + onSend={onSend} + filePaths={['README.md']} + /> + {editing ? ( + 0} + getComposerEditGeneration={() => 0} + value={queued} + onChangeText={setQueued} + onSend={save} + filePaths={['queue.ts']} + editSession={{ cancel: vi.fn(), refuseCommand: () => false, locked: false }} + /> + ) : null} + + + ) + } + await act(async () => root.render()) + 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('[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('[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()) + }) }) diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.paste.web.test.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.paste.web.test.tsx index 49a34cd5f72..089dad5cdea 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.paste.web.test.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.paste.web.test.tsx @@ -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 diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx index 2713b00bea2..4116ac4e3fd 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx @@ -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(null) const fieldRef = inputRef ?? fallbackInputRef + const groupRef = useRef(null) const [focused, setFocused] = useState(false) const [picker, setPicker] = useState(null) const request = sessionOptions?.controller.optionPickerRequest @@ -296,17 +284,14 @@ export function MobileNativeChatComposer({ } } - const groupRef = useMobileNativeChatComposerRegion(menu.rows.length > 0, focused) - - return ( + const content = ( - {menu.rows.length > 0 ? ( - - ) : null} + {goal.strip} @@ -424,6 +409,15 @@ export function MobileNativeChatComposer({ ) + return ( + 0} + > + {content} + + ) } export function MobileNativeChatDraftComposer({ diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerAllocation.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerAllocation.tsx new file mode 100644 index 00000000000..06088a9534d --- /dev/null +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerAllocation.tsx @@ -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 + fieldRef: RefObject + hasMenu: boolean + children: ReactNode +} + +export function MobileNativeChatComposerAllocation({ children }: ComposerAllocationProps) { + return children +} + +export function MobileNativeChatComposerMenuReservation() { + return null +} + +export type ComposerAllocation = { + reserveRef: RefObject + reservation: number + fieldMaxHeight: number | undefined + onActiveChange: (active: boolean) => void + onIntrinsic: (size: ComposerMenuIntrinsicSize) => void +} + +export function useMobileNativeChatComposerAllocation(): ComposerAllocation | null { + return null +} diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerAllocation.web.test.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerAllocation.web.test.tsx new file mode 100644 index 00000000000..837c5abc5aa --- /dev/null +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerAllocation.web.test.tsx @@ -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('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((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( + '[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(null) + const field = useRef(null) + return ( + + + {}} + /> + + + + + ) + } + const render = (items: ComposerMenuRow[]) => ( + + + + ) + 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() + } + }) +} diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerAllocation.web.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerAllocation.web.tsx new file mode 100644 index 00000000000..a07c6040c2d --- /dev/null +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerAllocation.web.tsx @@ -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(null) + +export function useMobileNativeChatComposerAllocation() { + return useContext(AllocationContext) +} + +export function MobileNativeChatComposerAllocation({ + anchorRef, + fieldRef, + hasMenu, + children +}: ComposerAllocationProps) { + const { allocationHeight } = useMobileNativeChatLowerGeometry() + const lower = useMobileNativeChatLowerRegion() + const reserveRef = useRef(null) + const menuWasShown = useRef(false) + const [active, setActive] = useState(false) + const [intrinsic, setIntrinsic] = useState(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('[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 {children} +} + +export function MobileNativeChatComposerMenuReservation() { + const allocation = useMobileNativeChatComposerAllocation() + return allocation ? ( + + ) : null +} diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.web.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.web.tsx index 23aa30aeed8..95d2110296a 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.web.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerField.web.tsx @@ -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(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. diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx index 614f0047b79..cc553fe6d26 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenu.tsx @@ -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 + rows: readonly ComposerMenuRow[] + onPick: (row: ComposerMenuRow) => void + nestedScrollEnabled?: boolean +}) { + return ( + <> + + {props.rows.length > 0 ? ( + + + + ) : null} + + ) +} + export function MobileNativeChatComposerMenu({ rows, onPick, @@ -20,7 +45,7 @@ export function MobileNativeChatComposerMenu({ }): React.JSX.Element { useMobileColorScheme() return ( - + ({ 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, diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenuLayer.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenuLayer.tsx new file mode 100644 index 00000000000..cf39b418e42 --- /dev/null +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenuLayer.tsx @@ -0,0 +1,11 @@ +import type { ReactNode, RefObject } from 'react' +import type { View } from 'react-native' + +export type ComposerMenuLayerProps = { + anchorRef: RefObject + children: ReactNode +} + +export function MobileNativeChatComposerMenuLayer({ children }: ComposerMenuLayerProps) { + return children +} diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenuLayer.web.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenuLayer.web.tsx new file mode 100644 index 00000000000..bde29deb099 --- /dev/null +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMenuLayer.web.tsx @@ -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(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(null) + const measureIntrinsic = useCallback(() => { + const card = surfaceRef.current?.querySelector( + '[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('[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( + + + {children} + + , + placement.root + ) + : null +} diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu-size.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu-size.ts new file mode 100644 index 00000000000..0efa1b97650 --- /dev/null +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-composer-menu-size.ts @@ -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)) +} diff --git a/mobile/src/session/native-chat/composer/mobile-native-chat-structured-composer.ts b/mobile/src/session/native-chat/composer/mobile-native-chat-structured-composer.ts new file mode 100644 index 00000000000..5efa1fe1f80 --- /dev/null +++ b/mobile/src/session/native-chat/composer/mobile-native-chat-structured-composer.ts @@ -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 +} diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index 558db9c48a9..3a4e2ef6bc0 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -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."