From dccea75b0e57b93c99cfe9f1f772abf0d1283466 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:09:08 -0700 Subject: [PATCH] Fix active tab scrolling and editor keyboard layout on mobile (#5271) * Improve mobile session tab scrolling and editor keyboard layout - Auto-scroll the active session tab into view within the horizontal tab strip, using a new layout-aware utility with minimal scrolling. - Use visual viewport measurements in the editor WebView to accurately report keyboard insets and lift floating controls above the keyboard, as native events can under-report covered areas. * Fix active tab scrolling and normalize editor keyboard inset - Ensure the active session tab is scrolled into view immediately when its layout finishes to prevent it from remaining hidden off-screen. - Round, clamp, and validate WebView visualViewport bottom measurements to avoid rendering inconsistencies from fractional or invalid insets. - Trigger an immediate keyboard inset report on script initialization. * Update tabStripOffsetRef when scrolling mobile tab strip Ensure the cached offset is updated to match the next scroll position. Without this update, subsequent scroll calculations would use stale offset values. --- .../app/h/[hostId]/session/[worktreeId].tsx | 63 +++++++++++++++++- .../components/MobileRichMarkdownEditor.tsx | 25 ++++++- .../mobile-rich-markdown-editor-html.ts | 10 +-- ...kdown-editor-keyboard-inset-script.test.ts | 17 +++++ ...h-markdown-editor-keyboard-inset-script.ts | 28 ++++++++ mobile/src/session/tab-strip-scroll.test.ts | 66 +++++++++++++++++++ mobile/src/session/tab-strip-scroll.ts | 40 +++++++++++ 7 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.test.ts create mode 100644 mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.ts create mode 100644 mobile/src/session/tab-strip-scroll.test.ts create mode 100644 mobile/src/session/tab-strip-scroll.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 9046fcbf0..e4b2a03cc 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -131,6 +131,7 @@ import { type MobileNewTabAgentSettings } from '../../../../src/session/mobile-new-tab-agent-options' import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout' +import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll' import { createMobileSessionCreateWarningState, dismissMobileSessionCreateWarningState, @@ -431,6 +432,10 @@ function MarkdownReader({ onDiscard: () => void keyboardLift: number }) { + // The editor lives in a WebView; native Keyboard events under-report its + // covered area, so prefer the inset measured inside the WebView when larger. + const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0) + const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset) if (!doc || doc.status === 'loading') { return ( @@ -469,6 +474,7 @@ function MarkdownReader({ content={doc.localContent} editable={doc.editable && !doc.saving} onChange={onChange} + onKeyboardInsetChange={setWebviewKeyboardInset} /> {showFloatingActions ? ( (null) const [activeSessionTabId, setActiveSessionTabId] = useState(null) const activeSessionTabIdRef = useRef(null) + // Auto-scroll the tab strip so the active tab (synced from desktop on + // worktree entry) is revealed without a manual scroll. + const tabStripRef = useRef(null) + const tabStripOffsetRef = useRef(0) + const tabStripViewportWidthRef = useRef(0) + const tabStripContentWidthRef = useRef(0) + const tabLayoutsRef = useRef>(new Map()) const [markdownDocs, setMarkdownDocs] = useState>(new Map()) const markdownDocsRef = useRef>(new Map()) const [fileDocs, setFileDocs] = useState>(new Map()) @@ -2295,6 +2308,34 @@ export default function SessionScreen() { } }, []) + const scrollActiveTabIntoView = useCallback((tabId: string | null, animated: boolean) => { + if (!tabId) { + return + } + const layout = tabLayoutsRef.current.get(tabId) + if (!layout) { + return + } + const nextOffset = resolveTabStripScrollOffset({ + tabX: layout.x, + tabWidth: layout.width, + viewportWidth: tabStripViewportWidthRef.current, + contentWidth: tabStripContentWidthRef.current, + currentOffset: tabStripOffsetRef.current + }) + if (nextOffset !== tabStripOffsetRef.current) { + tabStripOffsetRef.current = nextOffset + tabStripRef.current?.scrollTo({ x: nextOffset, animated }) + } + }, []) + + // Reveal the active tab whenever it changes (e.g. desktop's open tab synced on + // worktree entry). Defer one frame so freshly mounted tab layouts are recorded. + useEffect(() => { + const id = requestAnimationFrame(() => scrollActiveTabIntoView(activeSessionTabId, true)) + return () => cancelAnimationFrame(id) + }, [activeSessionTabId, scrollActiveTabIntoView]) + useEffect(() => { if (hostId && worktreeId) { void AsyncStorage.setItem( @@ -3867,16 +3908,36 @@ export default function SessionScreen() { (#5106); leaving a non-live tab still closes the keyboard because the live input unmounts. */} { + tabStripOffsetRef.current = e.nativeEvent.contentOffset.x + }} + onLayout={(e) => { + tabStripViewportWidthRef.current = e.nativeEvent.layout.width + scrollActiveTabIntoView(activeSessionTabIdRef.current, false) + }} + onContentSizeChange={(width) => { + tabStripContentWidthRef.current = width + scrollActiveTabIntoView(activeSessionTabIdRef.current, false) + }} > {visibleTabs.map((t) => ( { + const { x, width } = e.nativeEvent.layout + tabLayoutsRef.current.set(t.id, { x, width }) + if (t.id === activeSessionTabIdRef.current) { + scrollActiveTabIntoView(t.id, false) + } + }} onPress={() => switchSessionTab(t)} onLongPress={() => { triggerMediumImpact() diff --git a/mobile/src/components/MobileRichMarkdownEditor.tsx b/mobile/src/components/MobileRichMarkdownEditor.tsx index e50bc2e19..c9e0b44e0 100644 --- a/mobile/src/components/MobileRichMarkdownEditor.tsx +++ b/mobile/src/components/MobileRichMarkdownEditor.tsx @@ -19,6 +19,7 @@ import { } from 'lucide-react-native' import WebView, { type WebViewMessageEvent } from 'react-native-webview' import { colors, radii, spacing } from '../theme/mobile-theme' +import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset-script' import { buildMobileRichMarkdownEditorHtml, escapeInjectedJavaScriptString @@ -73,12 +74,14 @@ type Props = { content: string editable: boolean onChange: (content: string) => void + onKeyboardInsetChange?: (bottom: number) => void } type EditorWebViewMessage = | { type: 'ready' } | { type: 'change'; markdown: string; generation: number } | { type: 'openLink'; url: string } + | { type: 'keyboardInset'; bottom: number } type ToolbarItem = { command: RichMarkdownCommand @@ -104,7 +107,12 @@ const TOOLBAR_ITEMS: ToolbarItem[] = [ { command: 'codeBlock', label: 'Code block', icon: FileCode2 } ] -function MobileRichMarkdownEditorInner({ content, editable, onChange }: Props) { +function MobileRichMarkdownEditorInner({ + content, + editable, + onChange, + onKeyboardInsetChange +}: Props) { const webViewRef = useRef(null) const readyRef = useRef(false) const documentGenerationRef = useRef(0) @@ -150,6 +158,12 @@ function MobileRichMarkdownEditorInner({ content, editable, onChange }: Props) { } }, [applyEditable, editable]) + // Clear any reported keyboard inset when the editor unmounts so a lifted + // Save/Discard bar settles back once the tab closes. + useEffect(() => { + return () => onKeyboardInsetChange?.(0) + }, [onKeyboardInsetChange]) + const handleMessage = useCallback( (event: WebViewMessageEvent) => { let message: unknown @@ -182,9 +196,16 @@ function MobileRichMarkdownEditorInner({ content, editable, onChange }: Props) { if (url) { void Linking.openURL(url).catch(() => {}) } + return + } + if (editorMessage.type === 'keyboardInset' && typeof editorMessage.bottom === 'number') { + const bottom = normalizeMobileRichMarkdownKeyboardInset(editorMessage.bottom) + if (bottom !== null) { + onKeyboardInsetChange?.(bottom) + } } }, - [applyContent, applyEditable, content, editable, onChange] + [applyContent, applyEditable, content, editable, onChange, onKeyboardInsetChange] ) const handleShouldStartLoadWithRequest = useCallback((request: { url?: string }) => { diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.ts b/mobile/src/components/mobile-rich-markdown-editor-html.ts index 826bd1693..32fc12b59 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-html.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-html.ts @@ -1,4 +1,5 @@ import { colors } from '../theme/mobile-theme' +import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script' export function escapeInjectedJavaScriptString(value: string): string { return JSON.stringify(value).replace(/<\/script/gi, '<\\/script') @@ -669,13 +670,8 @@ export function buildMobileRichMarkdownEditorHtml(): string { } }); - window.__orcaRichMarkdown = { - setMarkdown: setMarkdown, - setEditable: setEditable, - runCommand: runCommand, - currentMarkdown: currentMarkdown - }; - + window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown }; +${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT} post({ type: 'ready' }); })(); diff --git a/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.test.ts b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.test.ts new file mode 100644 index 000000000..c29b6ab5b --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset-script' + +describe('normalizeMobileRichMarkdownKeyboardInset', () => { + it('rounds finite inset measurements for native layout', () => { + expect(normalizeMobileRichMarkdownKeyboardInset(42.6)).toBe(43) + }) + + it('clamps negative inset measurements to zero', () => { + expect(normalizeMobileRichMarkdownKeyboardInset(-8)).toBe(0) + }) + + it('rejects non-finite inset measurements', () => { + expect(normalizeMobileRichMarkdownKeyboardInset(Number.NaN)).toBeNull() + expect(normalizeMobileRichMarkdownKeyboardInset(Number.POSITIVE_INFINITY)).toBeNull() + }) +}) diff --git a/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.ts b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.ts new file mode 100644 index 000000000..4182d133f --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.ts @@ -0,0 +1,28 @@ +// In-page script that reports the height covered by the on-screen keyboard. +// Native Keyboard events are unreliable while focus lives in the editor +// WebView, so measure the covered region directly from visualViewport and let +// RN lift its native Save/Discard bar above it. +export function normalizeMobileRichMarkdownKeyboardInset(value: number): number | null { + if (!Number.isFinite(value)) { + return null + } + return Math.max(0, Math.round(value)) +} + +export const MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT = ` + var lastInset = -1; + function reportKeyboardInset() { + var viewport = window.visualViewport; + var bottom = viewport + ? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop) + : 0; + var rounded = Math.round(bottom); + if (rounded === lastInset) return; + lastInset = rounded; + post({ type: 'keyboardInset', bottom: rounded }); + } + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', reportKeyboardInset); + window.visualViewport.addEventListener('scroll', reportKeyboardInset); + reportKeyboardInset(); + }` diff --git a/mobile/src/session/tab-strip-scroll.test.ts b/mobile/src/session/tab-strip-scroll.test.ts new file mode 100644 index 000000000..fab517a31 --- /dev/null +++ b/mobile/src/session/tab-strip-scroll.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { resolveTabStripScrollOffset } from './tab-strip-scroll' + +describe('resolveTabStripScrollOffset', () => { + it('keeps the offset when the active tab is already fully visible', () => { + expect( + resolveTabStripScrollOffset({ + tabX: 140, + tabWidth: 128, + viewportWidth: 360, + contentWidth: 800, + currentOffset: 100 + }) + ).toBe(100) + }) + + it('scrolls left to reveal a tab off the left edge', () => { + expect( + resolveTabStripScrollOffset({ + tabX: 50, + tabWidth: 128, + viewportWidth: 360, + contentWidth: 800, + currentOffset: 200, + margin: 12 + }) + ).toBe(38) + }) + + it('scrolls right to reveal a tab off the right edge', () => { + expect( + resolveTabStripScrollOffset({ + tabX: 640, + tabWidth: 128, + viewportWidth: 360, + contentWidth: 900, + currentOffset: 0, + margin: 12 + }) + ).toBe(420) + }) + + it('clamps the offset to the content bounds', () => { + expect( + resolveTabStripScrollOffset({ + tabX: 880, + tabWidth: 128, + viewportWidth: 360, + contentWidth: 900, + currentOffset: 0 + }) + ).toBe(540) + }) + + it('returns the current offset when the viewport has not been measured', () => { + expect( + resolveTabStripScrollOffset({ + tabX: 100, + tabWidth: 128, + viewportWidth: 0, + contentWidth: 0, + currentOffset: 0 + }) + ).toBe(0) + }) +}) diff --git a/mobile/src/session/tab-strip-scroll.ts b/mobile/src/session/tab-strip-scroll.ts new file mode 100644 index 000000000..275b9ed5f --- /dev/null +++ b/mobile/src/session/tab-strip-scroll.ts @@ -0,0 +1,40 @@ +export type TabStripScrollInput = { + tabX: number + tabWidth: number + viewportWidth: number + contentWidth: number + currentOffset: number + margin?: number +} + +/** + * Keep active-tab reveal deterministic across async RN layout events without + * nudging the strip when the tab is already visible. + */ +export function resolveTabStripScrollOffset({ + tabX, + tabWidth, + viewportWidth, + contentWidth, + currentOffset, + margin = 12 +}: TabStripScrollInput): number { + const maxOffset = Math.max(0, contentWidth - viewportWidth) + if (viewportWidth <= 0) { + return currentOffset + } + + const visibleStart = currentOffset + const visibleEnd = currentOffset + viewportWidth + const tabStart = tabX + const tabEnd = tabX + tabWidth + + let nextOffset = currentOffset + if (tabStart < visibleStart + margin) { + nextOffset = tabStart - margin + } else if (tabEnd > visibleEnd - margin) { + nextOffset = tabEnd + margin - viewportWidth + } + + return Math.min(Math.max(0, nextOffset), maxOffset) +}