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.
This commit is contained in:
parent
f027a1cb7b
commit
dccea75b0e
|
|
@ -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 (
|
||||
<View style={styles.markdownState}>
|
||||
|
|
@ -469,6 +474,7 @@ function MarkdownReader({
|
|||
content={doc.localContent}
|
||||
editable={doc.editable && !doc.saving}
|
||||
onChange={onChange}
|
||||
onKeyboardInsetChange={setWebviewKeyboardInset}
|
||||
/>
|
||||
{showFloatingActions ? (
|
||||
<View
|
||||
|
|
@ -479,7 +485,7 @@ function MarkdownReader({
|
|||
// Save/Discard controls lifted instead of resizing that surface.
|
||||
{
|
||||
bottom: resolveMarkdownFloatingActionsBottom({
|
||||
keyboardLift,
|
||||
keyboardLift: effectiveKeyboardLift,
|
||||
restingBottom: spacing.lg,
|
||||
liftedClearance: spacing.md
|
||||
})
|
||||
|
|
@ -948,6 +954,13 @@ export default function SessionScreen() {
|
|||
const [activeHandle, setActiveHandle] = useState<string | null>(null)
|
||||
const [activeSessionTabId, setActiveSessionTabId] = useState<string | null>(null)
|
||||
const activeSessionTabIdRef = useRef<string | null>(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<ScrollView>(null)
|
||||
const tabStripOffsetRef = useRef(0)
|
||||
const tabStripViewportWidthRef = useRef(0)
|
||||
const tabStripContentWidthRef = useRef(0)
|
||||
const tabLayoutsRef = useRef<Map<string, { x: number; width: number }>>(new Map())
|
||||
const [markdownDocs, setMarkdownDocs] = useState<Map<string, MarkdownDocState>>(new Map())
|
||||
const markdownDocsRef = useRef<Map<string, MarkdownDocState>>(new Map())
|
||||
const [fileDocs, setFileDocs] = useState<Map<string, FileDocState>>(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. */}
|
||||
<ScrollView
|
||||
ref={tabStripRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.tabScroll}
|
||||
contentContainerStyle={styles.tabContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
scrollEventThrottle={16}
|
||||
onScroll={(e) => {
|
||||
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) => (
|
||||
<Pressable
|
||||
key={t.id}
|
||||
style={[styles.tab, t.id === activeSessionTabId && styles.tabActive]}
|
||||
onLayout={(e) => {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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<WebView>(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 }) => {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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();
|
||||
}`
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue