fix(desktop): overhaul touch-H5 interaction (#780)

Mark <html data-touch-h5> before first paint when the bundle runs in a
phone browser (no Electron host + coarse pointer) and scope every
mobile-only fix under it, so desktop shells and desktop browsers are
untouched:

- raise form controls to 16px and cap the iOS viewport scale, so
  focusing the composer no longer zooms the page and never zooms back
- disable content-visibility paint skipping on transcript/trace rows
  there (long-press selection on iOS WebKit jumps or drops selections
  when they extend into skipped rows); halve the virtualization
  thresholds on touch as the replacement paint bound for long sessions
- size the app shell to visualViewport so the composer rides the soft
  keyboard instead of being covered, snap back WebKit's keyboard pan,
  and keep the transcript tail pinned while the container shrinks
- pad the shell with safe-area insets (viewport-fit=cover) and drop the
  bottom inset while the keyboard is up
- keep message action bars (copy/branch) always visible on touch since
  hover never fires there; kill the WKWebView tap flash and body
  rubber-banding
- move the two inline content-visibility styles (trace message blocks,
  trace list rows) to classes so the touch scope can reach them

Tested: cd desktop && npx vitest run (1474 tests)
Tested: cd desktop && npx tsc --noEmit && npx vite build
Tested: Playwright chromium smoke against the built dist - desktop
context unchanged, iPhone/WeChat and Android contexts get the marker,
viewport lock, 16px controls, visible action bars and selectable rows
This commit is contained in:
程序员阿江(Relakkes) 2026-06-13 09:16:20 +08:00
parent 01d8691673
commit 6818db34fb
8 changed files with 767 additions and 20 deletions

View File

@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MessageList, buildRenderModel } from './MessageList'
import { MessageList, buildRenderModel, shouldVirtualizeRenderItems } from './MessageList'
import type { VirtualRenderItemMetric } from './virtualHeightCache'
import { relativizeWorkspacePath } from './CurrentTurnChangeCard'
import { sessionsApi } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
@ -4031,3 +4032,37 @@ describe('MessageList nested tool calls', () => {
expect(screen.queryByText(/This model does not support images/)).toBeNull()
})
})
describe('shouldVirtualizeRenderItems', () => {
const metric = (contentWeight: number): VirtualRenderItemMetric => ({
signature: 'sig',
contentWeight,
estimatedHeight: 100,
})
it('virtualizes at the desktop thresholds (120 items / 120k chars)', () => {
expect(shouldVirtualizeRenderItems(Array.from({ length: 119 }, () => metric(10)), false)).toBe(false)
expect(shouldVirtualizeRenderItems(Array.from({ length: 120 }, () => metric(10)), false)).toBe(true)
expect(shouldVirtualizeRenderItems([metric(119_999)], false)).toBe(false)
expect(shouldVirtualizeRenderItems([metric(120_000)], false)).toBe(true)
})
it('virtualizes at half the thresholds on touch-H5, where content-visibility is disabled', () => {
expect(shouldVirtualizeRenderItems(Array.from({ length: 59 }, () => metric(10)), true)).toBe(false)
expect(shouldVirtualizeRenderItems(Array.from({ length: 60 }, () => metric(10)), true)).toBe(true)
expect(shouldVirtualizeRenderItems([metric(59_999)], true)).toBe(false)
expect(shouldVirtualizeRenderItems([metric(60_000)], true)).toBe(true)
})
it('defaults the touch flag from the document marker', () => {
const metrics = Array.from({ length: 60 }, () => metric(10))
expect(shouldVirtualizeRenderItems(metrics)).toBe(false)
document.documentElement.setAttribute('data-touch-h5', 'true')
try {
expect(shouldVirtualizeRenderItems(metrics)).toBe(true)
} finally {
document.documentElement.removeAttribute('data-touch-h5')
}
})
})

View File

@ -24,6 +24,7 @@ import { InlineTaskSummary } from './InlineTaskSummary'
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
import { formatTokenCount } from '../../lib/formatTokenCount'
import { isTouchH5Document } from '../../lib/touchH5'
import { ConfirmDialog } from '../shared/ConfirmDialog'
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
import {
@ -873,6 +874,11 @@ const SCROLL_BOTTOM_SENTINEL = 1_000_000_000
const MAX_SCROLL_SNAPSHOTS = 100
const VIRTUALIZE_MIN_RENDER_ITEMS = 120
const VIRTUALIZE_MIN_CONTENT_CHARS = 120_000
// Touch-H5 disables content-visibility paint skipping for selection
// correctness (globals.css), which makes virtualization the only paint bound
// for long transcripts there — so it kicks in at half the desktop thresholds.
const TOUCH_H5_VIRTUALIZE_MIN_RENDER_ITEMS = 60
const TOUCH_H5_VIRTUALIZE_MIN_CONTENT_CHARS = 60_000
const VIRTUAL_OVERSCAN_PX = 1200
const VIRTUAL_DEFAULT_VIEWPORT_HEIGHT = 720
const VIRTUAL_MIN_ITEM_HEIGHT = 48
@ -1032,13 +1038,18 @@ function getRenderItemContentWeight(item: RenderItem): number {
return item.toolCalls.reduce((total, toolCall) => total + getMessageContentWeight(toolCall), 0)
}
function shouldVirtualizeRenderItems(metrics: VirtualRenderItemMetric[]) {
if (metrics.length >= VIRTUALIZE_MIN_RENDER_ITEMS) return true
export function shouldVirtualizeRenderItems(
metrics: VirtualRenderItemMetric[],
touchH5 = isTouchH5Document(),
) {
const minRenderItems = touchH5 ? TOUCH_H5_VIRTUALIZE_MIN_RENDER_ITEMS : VIRTUALIZE_MIN_RENDER_ITEMS
const minContentChars = touchH5 ? TOUCH_H5_VIRTUALIZE_MIN_CONTENT_CHARS : VIRTUALIZE_MIN_CONTENT_CHARS
if (metrics.length >= minRenderItems) return true
let totalWeight = 0
for (const metric of metrics) {
totalWeight += metric.contentWeight
if (totalWeight >= VIRTUALIZE_MIN_CONTENT_CHARS) return true
if (totalWeight >= minContentChars) return true
}
return false
}
@ -1589,6 +1600,24 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
return () => observer.disconnect()
}, [scrollToBottom, shouldFollowContentResize])
// Touch-H5 only: the visual-viewport fit (touchH5.ts) shrinks the scroll
// container when the soft keyboard opens. If the user was reading the tail,
// keep the latest message pinned above the keyboard instead of letting the
// shorter container cut it off.
useEffect(() => {
if (!isTouchH5Document()) return
const container = scrollContainerRef.current
if (!container || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(() => {
if (!shouldAutoScrollRef.current) return
scrollToBottom('auto')
})
observer.observe(container)
return () => observer.disconnect()
}, [scrollToBottom])
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(
() => buildRenderModel(messages, activeAskUserQuestionToolUseId),
[activeAskUserQuestionToolUseId, messages],

View File

@ -1,4 +1,4 @@
import { useState, type CSSProperties } from 'react'
import { useState } from 'react'
import { Wrench } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import type { NormalizedBlock, NormalizedMessage } from '../../../lib/trace/types'
@ -8,12 +8,6 @@ import { CodeViewer } from '../../chat/CodeViewer'
const LONG_TEXT_CHARS = 2000
/** Skip off-screen paint without virtualization (WebKit-friendly). */
const MESSAGE_CV_STYLE = {
contentVisibility: 'auto',
containIntrinsicSize: 'auto 120px',
} as const satisfies CSSProperties
const ROLE_STYLES: Record<NormalizedMessage['role'], { badge: string; container: string }> = {
user: {
badge: 'text-[var(--color-info)]',
@ -37,8 +31,7 @@ export function MessageBlocks({ message }: { message: NormalizedMessage }) {
const styles = ROLE_STYLES[message.role]
return (
<div
className={`rounded-[var(--radius-md)] border-l-2 px-3 py-2 ${styles.container}`}
style={MESSAGE_CV_STYLE}
className={`trace-message-cv rounded-[var(--radius-md)] border-l-2 px-3 py-2 ${styles.container}`}
data-testid={`trace-message-${message.role}`}
>
<div className={`text-[10px] font-semibold uppercase tracking-[0.12em] ${styles.badge}`}>

View File

@ -0,0 +1,431 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
TOUCH_H5_ATTRIBUTE,
TOUCH_H5_KEYBOARD_ATTRIBUTE,
TOUCH_H5_VIEWPORT_HEIGHT_VAR,
detectTouchH5Environment,
initializeTouchH5,
isIOSEnvironment,
isTouchH5Document,
isTouchH5Environment,
type TouchH5Environment,
} from './touchH5'
function makeEnv(overrides: Partial<TouchH5Environment> = {}): TouchH5Environment {
return {
hasDesktopHost: false,
coarsePointer: false,
maxTouchPoints: 0,
userAgent: 'Mozilla/5.0',
platform: 'MacIntel',
...overrides,
}
}
const IPHONE_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0'
const ANDROID_UA =
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36'
type MockWindowEnvOptions = {
desktopHost?: boolean
coarsePointer?: boolean
maxTouchPoints?: number
userAgent?: string
platform?: string
}
const originalDescriptors: Array<{ target: object; key: string; descriptor: PropertyDescriptor | undefined }> = []
function defineMocked(target: object, key: string, value: unknown) {
originalDescriptors.push({ target, key, descriptor: Object.getOwnPropertyDescriptor(target, key) })
Object.defineProperty(target, key, { value, configurable: true, writable: true })
}
function mockWindowEnv({
desktopHost = false,
coarsePointer = false,
maxTouchPoints = 0,
userAgent = 'Mozilla/5.0',
platform = 'MacIntel',
}: MockWindowEnvOptions) {
if (desktopHost) {
defineMocked(window, 'desktopHost', { isDesktop: true })
}
defineMocked(window, 'matchMedia', (query: string) => ({
matches: query === '(pointer: coarse)' ? coarsePointer : false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}))
defineMocked(navigator, 'maxTouchPoints', maxTouchPoints)
defineMocked(navigator, 'userAgent', userAgent)
defineMocked(navigator, 'platform', platform)
}
function getViewportMeta() {
return document.querySelector('meta[name="viewport"]')
}
beforeEach(() => {
defineMocked(window, 'requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0)
return 0
})
defineMocked(window, 'scrollTo', vi.fn())
})
afterEach(() => {
while (originalDescriptors.length > 0) {
const { target, key, descriptor } = originalDescriptors.pop()!
if (descriptor) {
Object.defineProperty(target, key, descriptor)
} else {
delete (target as Record<string, unknown>)[key]
}
}
document.documentElement.removeAttribute(TOUCH_H5_ATTRIBUTE)
getViewportMeta()?.remove()
document.body.innerHTML = ''
vi.restoreAllMocks()
})
describe('isTouchH5Environment', () => {
it('rejects the Electron desktop shell even on touch hardware', () => {
expect(isTouchH5Environment(makeEnv({ hasDesktopHost: true, coarsePointer: true, maxTouchPoints: 5 }))).toBe(false)
})
it('rejects desktop browsers with a fine pointer and no touch points', () => {
expect(isTouchH5Environment(makeEnv())).toBe(false)
})
it('accepts coarse-pointer browsers', () => {
expect(isTouchH5Environment(makeEnv({ coarsePointer: true }))).toBe(true)
})
it('accepts touch devices even when matchMedia is unavailable', () => {
expect(isTouchH5Environment(makeEnv({ coarsePointer: false, maxTouchPoints: 5 }))).toBe(true)
})
})
describe('isIOSEnvironment', () => {
it('matches iPhone user agents', () => {
expect(isIOSEnvironment(makeEnv({ userAgent: IPHONE_UA }))).toBe(true)
})
it('matches iPadOS 13+ masquerading as macOS', () => {
expect(isIOSEnvironment(makeEnv({ platform: 'MacIntel', maxTouchPoints: 5 }))).toBe(true)
})
it('rejects real macOS and Android', () => {
expect(isIOSEnvironment(makeEnv({ platform: 'MacIntel', maxTouchPoints: 0 }))).toBe(false)
expect(isIOSEnvironment(makeEnv({ userAgent: ANDROID_UA, platform: 'Linux armv81' }))).toBe(false)
})
})
describe('detectTouchH5Environment', () => {
it('reads desktop host, pointer, and navigator facts from the window', () => {
mockWindowEnv({ desktopHost: true, coarsePointer: true, maxTouchPoints: 5, userAgent: IPHONE_UA, platform: 'iPhone' })
expect(detectTouchH5Environment(window)).toEqual({
hasDesktopHost: true,
coarsePointer: true,
maxTouchPoints: 5,
userAgent: IPHONE_UA,
platform: 'iPhone',
})
})
it('treats a throwing matchMedia as a fine pointer', () => {
defineMocked(window, 'matchMedia', () => {
throw new Error('not implemented')
})
expect(detectTouchH5Environment(window).coarsePointer).toBe(false)
})
})
describe('initializeTouchH5', () => {
it('does nothing in the Electron desktop shell', () => {
mockWindowEnv({ desktopHost: true, coarsePointer: true, maxTouchPoints: 5 })
expect(initializeTouchH5(window)).toBe(false)
expect(document.documentElement.hasAttribute(TOUCH_H5_ATTRIBUTE)).toBe(false)
})
it('does nothing in desktop browsers', () => {
mockWindowEnv({})
expect(initializeTouchH5(window)).toBe(false)
expect(document.documentElement.hasAttribute(TOUCH_H5_ATTRIBUTE)).toBe(false)
expect(getViewportMeta()).toBeNull()
})
it('marks the document on Android touch browsers without touching the viewport', () => {
// Stub window: keeps the shared jsdom window out of the module-level
// listener guard, so the iOS cases below install against a fresh slate.
const { win, doc } = createStubWindow({ userAgent: ANDROID_UA, platform: 'Linux armv81' })
expect(initializeTouchH5(win)).toBe(true)
expect(doc.documentElement.getAttribute(TOUCH_H5_ATTRIBUTE)).toBe('true')
expect(doc.querySelector('meta[name="viewport"]')).toBeNull()
})
it('marks the document and rewrites an existing viewport meta on iOS', () => {
const meta = document.createElement('meta')
meta.setAttribute('name', 'viewport')
meta.setAttribute('content', 'width=device-width, initial-scale=1.0')
document.head.appendChild(meta)
mockWindowEnv({ coarsePointer: true, maxTouchPoints: 5, userAgent: IPHONE_UA, platform: 'iPhone' })
expect(initializeTouchH5(window)).toBe(true)
expect(document.documentElement.getAttribute(TOUCH_H5_ATTRIBUTE)).toBe('true')
const content = getViewportMeta()?.getAttribute('content') ?? ''
expect(content).toContain('maximum-scale=1.0')
expect(content).toContain('user-scalable=no')
expect(content).toContain('width=device-width')
})
it('creates the viewport meta on iOS when none exists and stays single across re-runs', () => {
mockWindowEnv({ coarsePointer: true, maxTouchPoints: 5, userAgent: IPHONE_UA, platform: 'iPhone' })
expect(initializeTouchH5(window)).toBe(true)
expect(initializeTouchH5(window)).toBe(true)
const metas = document.querySelectorAll('meta[name="viewport"]')
expect(metas.length).toBe(1)
expect(metas[0]?.getAttribute('content')).toContain('maximum-scale=1.0')
})
it('snaps the page back to the top when the iOS keyboard collapses', () => {
mockWindowEnv({ coarsePointer: true, maxTouchPoints: 5, userAgent: IPHONE_UA, platform: 'iPhone' })
initializeTouchH5(window)
initializeTouchH5(window)
const textarea = document.createElement('textarea')
document.body.appendChild(textarea)
defineMocked(window, 'scrollY', 40)
const scrollTo = vi.mocked(window.scrollTo)
scrollTo.mockClear()
textarea.dispatchEvent(new Event('focusout', { bubbles: true }))
// Installed once despite repeated initialization.
expect(scrollTo).toHaveBeenCalledTimes(1)
expect(scrollTo).toHaveBeenCalledWith(0, 0)
})
it('leaves the scroll position alone when focus moves to another input', () => {
mockWindowEnv({ coarsePointer: true, maxTouchPoints: 5, userAgent: IPHONE_UA, platform: 'iPhone' })
initializeTouchH5(window)
const first = document.createElement('textarea')
const second = document.createElement('input')
document.body.append(first, second)
defineMocked(window, 'scrollY', 40)
second.focus()
const scrollTo = vi.mocked(window.scrollTo)
scrollTo.mockClear()
first.dispatchEvent(new Event('focusout', { bubbles: true }))
expect(scrollTo).not.toHaveBeenCalled()
})
it('does not scroll when the page is already at the top', () => {
mockWindowEnv({ coarsePointer: true, maxTouchPoints: 5, userAgent: IPHONE_UA, platform: 'iPhone' })
initializeTouchH5(window)
const textarea = document.createElement('textarea')
document.body.appendChild(textarea)
const scrollTo = vi.mocked(window.scrollTo)
scrollTo.mockClear()
textarea.dispatchEvent(new Event('focusout', { bubbles: true }))
expect(scrollTo).not.toHaveBeenCalled()
})
})
class FakeVisualViewport extends EventTarget {
height: number
constructor(height: number) {
super()
this.height = height
}
}
type StubWindowOptions = {
desktopHost?: boolean
userAgent?: string
platform?: string
innerHeight?: number
visualViewportHeight?: number | null
}
/**
* A fresh window-like object per test: keeps the module-level WeakSet
* (listener install guard) from leaking state between cases, unlike the
* shared jsdom window.
*/
function createStubWindow({
desktopHost = false,
userAgent = IPHONE_UA,
platform = 'iPhone',
innerHeight = 900,
visualViewportHeight = 900,
}: StubWindowOptions = {}) {
const doc = document.implementation.createHTMLDocument()
const visualViewport =
visualViewportHeight === null ? null : new FakeVisualViewport(visualViewportHeight)
const win = {
document: doc,
navigator: { maxTouchPoints: 5, userAgent, platform },
matchMedia: (query: string) => ({ matches: query === '(pointer: coarse)' }),
visualViewport,
innerHeight,
scrollX: 0,
scrollY: 0,
scrollTo: vi.fn(),
addEventListener: vi.fn(),
requestAnimationFrame: (callback: FrameRequestCallback) => {
callback(0)
return 0
},
HTMLElement: window.HTMLElement,
desktopHost: desktopHost ? { isDesktop: true } : undefined,
}
return { win: win as unknown as Window & typeof globalThis, doc, visualViewport }
}
describe('isTouchH5Document', () => {
it('reflects the marker set by initializeTouchH5', () => {
const { win, doc } = createStubWindow()
expect(isTouchH5Document(doc)).toBe(false)
initializeTouchH5(win)
expect(isTouchH5Document(doc)).toBe(true)
})
it('is false for unmarked documents and missing documents', () => {
expect(isTouchH5Document(document)).toBe(false)
expect(isTouchH5Document(undefined)).toBe(false)
})
})
describe('visual viewport fit', () => {
it('locks the iOS viewport with viewport-fit=cover for safe-area insets', () => {
const { win, doc } = createStubWindow()
initializeTouchH5(win)
expect(doc.querySelector('meta[name="viewport"]')?.getAttribute('content')).toContain(
'viewport-fit=cover',
)
})
it('publishes the visual viewport height as a CSS variable on init', () => {
const { win, doc } = createStubWindow({ innerHeight: 812, visualViewportHeight: 812 })
initializeTouchH5(win)
expect(doc.documentElement.style.getPropertyValue(TOUCH_H5_VIEWPORT_HEIGHT_VAR)).toBe('812px')
expect(doc.documentElement.hasAttribute(TOUCH_H5_KEYBOARD_ATTRIBUTE)).toBe(false)
})
it('marks the keyboard open and tracks the shrunken height on resize', () => {
const { win, doc, visualViewport } = createStubWindow({ innerHeight: 900, visualViewportHeight: 900 })
initializeTouchH5(win)
visualViewport!.height = 520
visualViewport!.dispatchEvent(new Event('resize'))
expect(doc.documentElement.style.getPropertyValue(TOUCH_H5_VIEWPORT_HEIGHT_VAR)).toBe('520px')
expect(doc.documentElement.hasAttribute(TOUCH_H5_KEYBOARD_ATTRIBUTE)).toBe(true)
})
it('snaps the WebKit keyboard pan back to the origin while the keyboard is open', () => {
const { win, visualViewport } = createStubWindow({ innerHeight: 900, visualViewportHeight: 900 })
initializeTouchH5(win)
;(win as { scrollY: number }).scrollY = 140
visualViewport!.height = 520
visualViewport!.dispatchEvent(new Event('resize'))
expect(win.scrollTo).toHaveBeenCalledWith(0, 0)
})
it('clears the keyboard marker when the keyboard collapses', () => {
const { win, doc, visualViewport } = createStubWindow({ innerHeight: 900, visualViewportHeight: 900 })
initializeTouchH5(win)
visualViewport!.height = 520
visualViewport!.dispatchEvent(new Event('resize'))
visualViewport!.height = 900
visualViewport!.dispatchEvent(new Event('resize'))
expect(doc.documentElement.hasAttribute(TOUCH_H5_KEYBOARD_ATTRIBUTE)).toBe(false)
expect(doc.documentElement.style.getPropertyValue(TOUCH_H5_VIEWPORT_HEIGHT_VAR)).toBe('900px')
})
it('installs the viewport listeners only once across re-initialization', () => {
const { win, visualViewport } = createStubWindow()
const addListener = vi.spyOn(visualViewport!, 'addEventListener')
initializeTouchH5(win)
const installedCount = addListener.mock.calls.length
initializeTouchH5(win)
expect(installedCount).toBeGreaterThan(0)
expect(addListener.mock.calls.length).toBe(installedCount)
})
it('tolerates browsers without visualViewport', () => {
const { win, doc } = createStubWindow({ visualViewportHeight: null })
expect(initializeTouchH5(win)).toBe(true)
expect(doc.documentElement.style.getPropertyValue(TOUCH_H5_VIEWPORT_HEIGHT_VAR)).toBe('')
})
})
describe('touch-H5 stylesheet contract', () => {
const css = readFileSync(join(__dirname, '../theme/globals.css'), 'utf-8')
it('scopes phone-only rules under the touch-h5 attribute', () => {
expect(css).toContain('html[data-touch-h5]')
})
it('raises form-control font size to the iOS no-zoom threshold', () => {
expect(css).toMatch(/html\[data-touch-h5\] input,\s*\nhtml\[data-touch-h5\] textarea,\s*\nhtml\[data-touch-h5\] select \{\s*\n\s*font-size: 16px;/)
})
it('disables content-visibility paint skipping for selectable transcript rows', () => {
expect(css).toMatch(/html\[data-touch-h5\] \.chat-render-item--cv,\s*\nhtml\[data-touch-h5\] \.trace-row-cv,/)
})
it('keeps the fixed shell from rubber-banding', () => {
expect(css).toMatch(/html\[data-touch-h5\] body \{\s*\n\s*overscroll-behavior-y: none;/)
})
it('sizes the app shell to the visual viewport with a dvh fallback', () => {
expect(css).toMatch(/html\[data-touch-h5\] \.app-shell-viewport \{[^}]*height: var\(--touch-h5-viewport-height, 100dvh\);/)
})
it('pads the shell for safe areas and drops the bottom inset under the keyboard', () => {
expect(css).toMatch(/html\[data-touch-h5\] \.app-shell-viewport \{[^}]*padding-bottom: env\(safe-area-inset-bottom, 0px\);/)
expect(css).toMatch(/html\[data-touch-h5\]\[data-touch-h5-keyboard\] \.app-shell-viewport \{\s*\n\s*padding-bottom: 0px;/)
})
it('keeps message action bars always visible on touch', () => {
expect(css).toMatch(/html\[data-touch-h5\] \[data-message-actions\] \{\s*\n\s*opacity: 1;\s*\n\s*pointer-events: auto;/)
})
it('disables paint skipping for the trace-window rows too', () => {
expect(css).toMatch(/\.trace-message-cv \{\s*\n\s*content-visibility: auto;\s*\n\s*contain-intrinsic-size: auto 120px;/)
expect(css).toMatch(/\.trace-list-row-cv \{\s*\n\s*content-visibility: auto;\s*\n\s*contain-intrinsic-size: auto 56px;/)
expect(css).toMatch(/html\[data-touch-h5\] \.trace-message-cv,\s*\nhtml\[data-touch-h5\] \.trace-list-row-cv \{\s*\n\s*content-visibility: visible;/)
})
})

182
desktop/src/lib/touchH5.ts Normal file
View File

@ -0,0 +1,182 @@
/**
* Touch-H5 runtime marker.
*
* The same bundle serves three runtimes: the Electron desktop shell, desktop
* browsers, and phone browsers reaching the H5 server (WeChat scan, Safari,
* etc.). Phone WebKit needs a handful of behavior fixes (focus auto-zoom,
* text-selection vs content-visibility, rubber-band scrolling) that must NOT
* leak into the desktop runtimes, so instead of scattering UA checks through
* components we mark `<html data-touch-h5>` once before first paint and scope
* every mobile-only CSS rule under that attribute (see globals.css).
*
* Runs synchronously at module-load time in main.tsx before React mounts
* so it must stay dependency-free (no api client, no stores).
*/
export const TOUCH_H5_ATTRIBUTE = 'data-touch-h5'
export const TOUCH_H5_KEYBOARD_ATTRIBUTE = 'data-touch-h5-keyboard'
export const TOUCH_H5_VIEWPORT_HEIGHT_VAR = '--touch-h5-viewport-height'
/** True when initializeTouchH5 marked this document as a touch-H5 runtime. */
export function isTouchH5Document(
doc: Document | undefined = typeof document === 'undefined' ? undefined : document,
): boolean {
return !!doc?.documentElement.hasAttribute(TOUCH_H5_ATTRIBUTE)
}
export type TouchH5Environment = {
/** Electron preload injects `window.desktopHost`; its absence means browser. */
hasDesktopHost: boolean
/** Primary pointer is coarse (touch) — phones/tablets, not touch laptops. */
coarsePointer: boolean
maxTouchPoints: number
userAgent: string
platform: string
}
type WindowLike = Window & typeof globalThis
export function detectTouchH5Environment(win: WindowLike = window): TouchH5Environment {
const nav = win.navigator
let coarsePointer = false
try {
coarsePointer = typeof win.matchMedia === 'function' && win.matchMedia('(pointer: coarse)').matches
} catch {
coarsePointer = false
}
return {
hasDesktopHost: !!win.desktopHost,
coarsePointer,
maxTouchPoints: typeof nav?.maxTouchPoints === 'number' ? nav.maxTouchPoints : 0,
userAgent: nav?.userAgent ?? '',
platform: nav?.platform ?? '',
}
}
export function isTouchH5Environment(env: TouchH5Environment): boolean {
if (env.hasDesktopHost) return false
return env.coarsePointer || env.maxTouchPoints > 0
}
/** iPadOS 13+ masquerades as macOS; the touch-point count gives it away. */
export function isIOSEnvironment(env: TouchH5Environment): boolean {
if (/iPad|iPhone|iPod/.test(env.userAgent)) return true
return env.platform === 'MacIntel' && env.maxTouchPoints > 1
}
const IOS_VIEWPORT_CONTENT =
'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'
/**
* iOS WKWebView (Safari and the WeChat in-app browser) zooms the whole page
* when a focused form control renders below 16px, and never zooms back out.
* Raising control font sizes (globals.css) fixes the trigger; capping the
* viewport scale also stops double-tap/pinch zoom from leaving the chat shell
* in a half-zoomed state. Applied only on iOS Android has no focus-zoom
* behavior, so it keeps pinch-zoom accessibility.
*/
function lockIOSViewport(doc: Document) {
const viewport = doc.querySelector('meta[name="viewport"]')
if (viewport) {
viewport.setAttribute('content', IOS_VIEWPORT_CONTENT)
return
}
const meta = doc.createElement('meta')
meta.setAttribute('name', 'viewport')
meta.setAttribute('content', IOS_VIEWPORT_CONTENT)
doc.head.appendChild(meta)
}
/**
* iOS WKWebView (notoriously the WeChat one) leaves the page scrolled up
* after the soft keyboard collapses, so the fixed app shell sits half off
* screen. The body never legitimately scrolls (the shell is 100dvh with
* inner scroll areas), so snapping back to 0 after an input blurs is safe.
*/
function installIOSKeyboardCollapseFix(win: WindowLike) {
win.addEventListener('focusout', (event) => {
const target = event.target
if (!(target instanceof win.HTMLElement)) return
if (!/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) && !target.isContentEditable) return
win.requestAnimationFrame(() => {
const active = win.document.activeElement
const refocusedInput =
active instanceof win.HTMLElement &&
(/^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName) || active.isContentEditable)
if (refocusedInput) return
if (win.scrollX !== 0 || win.scrollY !== 0) {
win.scrollTo(0, 0)
}
})
})
}
/**
* Keep the app shell sized to the *visual* viewport.
*
* 100dvh tracks browser chrome but NOT the soft keyboard: when the keyboard
* opens, iOS WebKit leaves the layout viewport tall and pans it upward so the
* focused field is visible pushing the header (and part of the transcript)
* off screen. Publishing visualViewport.height as a CSS variable lets the
* shell shrink to the visible area instead (globals.css), so the composer sits
* right above the keyboard with the transcript still readable. The pan is then
* redundant and gets snapped back. On browsers that already resize layout for
* the keyboard (Android Chrome default) the variable simply equals 100dvh.
*/
const KEYBOARD_VISIBLE_MIN_GAP_PX = 80
function installVisualViewportFit(win: WindowLike) {
const viewport = win.visualViewport
if (!viewport) return
const root = win.document.documentElement
const sync = () => {
const height = viewport.height
if (!Number.isFinite(height) || height <= 0) return
root.style.setProperty(TOUCH_H5_VIEWPORT_HEIGHT_VAR, `${Math.round(height)}px`)
const keyboardVisible = win.innerHeight - height > KEYBOARD_VISIBLE_MIN_GAP_PX
root.toggleAttribute(TOUCH_H5_KEYBOARD_ATTRIBUTE, keyboardVisible)
if (keyboardVisible && (win.scrollX !== 0 || win.scrollY !== 0)) {
win.scrollTo(0, 0)
}
}
viewport.addEventListener('resize', sync)
viewport.addEventListener('scroll', sync)
sync()
}
const windowListenersInstalled = new WeakSet<object>()
/**
* Mark the document for touch-H5 styling and apply iOS-specific fixes.
* No-op (and returns false) in the Electron shell and desktop browsers.
*/
export function initializeTouchH5(win: WindowLike | undefined = typeof window === 'undefined' ? undefined : window): boolean {
if (!win) return false
const env = detectTouchH5Environment(win)
if (!isTouchH5Environment(env)) return false
win.document.documentElement.setAttribute(TOUCH_H5_ATTRIBUTE, 'true')
const ios = isIOSEnvironment(env)
if (ios) {
lockIOSViewport(win.document)
}
if (!windowListenersInstalled.has(win)) {
windowListenersInstalled.add(win)
installVisualViewportFit(win)
if (ios) {
installIOSKeyboardCollapseFix(win)
}
}
return true
}

View File

@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import './theme/globals.css'
import { initializeAppZoom } from './lib/appZoom'
import { initializeTouchH5 } from './lib/touchH5'
import { runDesktopPersistenceMigrations } from './lib/persistenceMigrations'
declare global {
@ -61,6 +62,7 @@ export async function bootstrapDesktopApp(
}
runDesktopPersistenceMigrations()
initializeTouchH5()
void initializeAppZoom()
void bootstrapDesktopApp()

View File

@ -19,11 +19,6 @@ const PAGE_SIZE = 50
const SEARCH_DEBOUNCE_MS = 250
const MAX_MODEL_CHIPS = 2
/** Skip off-screen row rendering without virtualization (WebKit-friendly). */
const ROW_CV_STYLE = {
contentVisibility: 'auto',
containIntrinsicSize: 'auto 56px',
} as const
export function TraceList() {
const t = useTranslation()
@ -263,8 +258,7 @@ function TraceRow({
<div
role="listitem"
aria-label={title}
className="group flex h-14 cursor-pointer items-center gap-4 px-5 transition-colors hover:bg-[var(--color-surface-hover)]"
style={ROW_CV_STYLE}
className="trace-list-row-cv group flex h-14 cursor-pointer items-center gap-4 px-5 transition-colors hover:bg-[var(--color-surface-hover)]"
>
<button
type="button"

View File

@ -1441,3 +1441,84 @@ button, input, textarea, select, a, [role="button"] {
content-visibility: auto;
contain-intrinsic-size: auto 34px;
}
/* Trace-window variants of the same trick (kept as classes not inline styles
so the touch-H5 override below can reach them). */
.trace-message-cv {
content-visibility: auto;
contain-intrinsic-size: auto 120px;
}
.trace-list-row-cv {
content-visibility: auto;
contain-intrinsic-size: auto 56px;
}
/* ============================================================================
Touch-H5 (phone browser) scoped fixes.
`html[data-touch-h5]` is set before first paint by src/lib/touchH5.ts only
when the bundle runs in a browser (no Electron host) on a coarse-pointer
device WeChat scan-to-open, mobile Safari, etc. Desktop shells and
desktop browsers never match, so nothing here can regress desktop chat.
========================================================================== */
/* iOS WKWebView zooms the whole page (and never zooms back) when a focused
form control renders below 16px. 16px controls remove the trigger at the
root; the JS side additionally caps the viewport scale on iOS. Android has
no focus-zoom but 16px inputs are the native size there too. */
html[data-touch-h5] input,
html[data-touch-h5] textarea,
html[data-touch-h5] select {
font-size: 16px;
}
/* Long-press text selection on iOS WebKit fights content-visibility: auto
extending a selection into a skipped row forces synchronous layout, which
makes the selection handles jump or drops the selection entirely. The paint
savings were measured for the desktop WKWebView's 860px canvas; a phone
viewport paints far less per row, so correct selection wins here. */
html[data-touch-h5] .chat-render-item--cv,
html[data-touch-h5] .trace-row-cv,
html[data-touch-h5] .trace-message-cv,
html[data-touch-h5] .trace-list-row-cv {
content-visibility: visible;
}
/* Size the shell to the *visual* viewport so the composer rides the soft
keyboard instead of the keyboard covering it (variable published by
touchH5.ts; falls back to 100dvh before the first sync). The safe-area
padding pairs with viewport-fit=cover on iOS: keep the composer above the
home indicator and the header out of the notch, but drop the bottom inset
while the keyboard is up the keyboard covers that strip anyway. */
html[data-touch-h5] .app-shell-viewport {
min-height: var(--touch-h5-viewport-height, 100dvh);
height: var(--touch-h5-viewport-height, 100dvh);
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
html[data-touch-h5][data-touch-h5-keyboard] .app-shell-viewport {
padding-bottom: 0px;
}
/* Message action bars (copy / branch) are hover-revealed on desktop; touch
devices have no hover, so keep them always visible there. */
html[data-touch-h5] [data-message-actions] {
opacity: 1;
pointer-events: auto;
}
/* Kill the gray flash WKWebView paints over any tapped interactive element;
components carry their own pressed/hover styling. */
html[data-touch-h5],
html[data-touch-h5] * {
-webkit-tap-highlight-color: transparent;
}
/* The app shell is a fixed 100dvh layout with inner scroll areas; the body
itself never scrolls. Stop edge overscroll from rubber-banding the whole
shell (and WeChat from revealing its pull-down domain banner mid-chat). */
html[data-touch-h5] body {
overscroll-behavior-y: none;
}