diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 773d523a5aa..c9a388ee8a5 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -3933,6 +3933,9 @@ export default function SessionScreen() { 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={() => { diff --git a/mobile/src/components/MobileRichMarkdownEditor.tsx b/mobile/src/components/MobileRichMarkdownEditor.tsx index 4bd22d77b8a..c9e0b44e082 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 @@ -198,7 +199,10 @@ function MobileRichMarkdownEditorInner({ return } if (editorMessage.type === 'keyboardInset' && typeof editorMessage.bottom === 'number') { - onKeyboardInsetChange?.(editorMessage.bottom) + const bottom = normalizeMobileRichMarkdownKeyboardInset(editorMessage.bottom) + if (bottom !== null) { + onKeyboardInsetChange?.(bottom) + } } }, [applyContent, applyEditable, content, editable, onChange, onKeyboardInsetChange] 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 00000000000..c29b6ab5ba9 --- /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 index 4060e121d98..4182d133f00 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.ts @@ -2,6 +2,13 @@ // 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() { @@ -17,4 +24,5 @@ export const MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT = ` 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 index afda112cc42..fab517a3155 100644 --- a/mobile/src/session/tab-strip-scroll.test.ts +++ b/mobile/src/session/tab-strip-scroll.test.ts @@ -28,7 +28,6 @@ describe('resolveTabStripScrollOffset', () => { }) it('scrolls right to reveal a tab off the right edge', () => { - // tabEnd = 640 + 128 = 768; needs offset 768 + 12 - 360 = 420. expect( resolveTabStripScrollOffset({ tabX: 640, diff --git a/mobile/src/session/tab-strip-scroll.ts b/mobile/src/session/tab-strip-scroll.ts index edd25c89142..275b9ed5f57 100644 --- a/mobile/src/session/tab-strip-scroll.ts +++ b/mobile/src/session/tab-strip-scroll.ts @@ -1,24 +1,15 @@ export type TabStripScrollInput = { - /** Left edge of the active tab within the scroll content. */ tabX: number - /** Width of the active tab. */ tabWidth: number - /** Visible width of the horizontal tab strip. */ viewportWidth: number - /** Total scrollable content width. */ contentWidth: number - /** Current horizontal scroll offset. */ currentOffset: number - /** Margin to keep between the tab and the viewport edge when revealing. */ margin?: number } /** - * Resolve the horizontal offset that brings the active tab fully into view. - * - * Returns the current offset unchanged when the tab is already visible so we - * never scroll on redundant active-tab updates; otherwise reveals the tab with - * the nearest minimal scroll, clamped to the content bounds. + * Keep active-tab reveal deterministic across async RN layout events without + * nudging the strip when the tab is already visible. */ export function resolveTabStripScrollOffset({ tabX, @@ -40,10 +31,8 @@ export function resolveTabStripScrollOffset({ let nextOffset = currentOffset if (tabStart < visibleStart + margin) { - // Off the left edge: align the tab start with the left margin. nextOffset = tabStart - margin } else if (tabEnd > visibleEnd - margin) { - // Off the right edge: align the tab end with the right margin. nextOffset = tabEnd + margin - viewportWidth }