From 6818db34fb1c26fe091a3eede40237c70ebccff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 13 Jun 2026 09:16:20 +0800 Subject: [PATCH] fix(desktop): overhaul touch-H5 interaction (#780) Mark 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 --- .../src/components/chat/MessageList.test.tsx | 37 +- desktop/src/components/chat/MessageList.tsx | 35 +- .../components/trace/detail/MessageBlocks.tsx | 11 +- desktop/src/lib/touchH5.test.ts | 431 ++++++++++++++++++ desktop/src/lib/touchH5.ts | 182 ++++++++ desktop/src/main.tsx | 2 + desktop/src/pages/TraceList.tsx | 8 +- desktop/src/theme/globals.css | 81 ++++ 8 files changed, 767 insertions(+), 20 deletions(-) create mode 100644 desktop/src/lib/touchH5.test.ts create mode 100644 desktop/src/lib/touchH5.ts diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index e8e6db3d..b2e144f8 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -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') + } + }) +}) diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index a234f704..a7c467d5 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -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], diff --git a/desktop/src/components/trace/detail/MessageBlocks.tsx b/desktop/src/components/trace/detail/MessageBlocks.tsx index f920fef6..a9b91aa5 100644 --- a/desktop/src/components/trace/detail/MessageBlocks.tsx +++ b/desktop/src/components/trace/detail/MessageBlocks.tsx @@ -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 = { user: { badge: 'text-[var(--color-info)]', @@ -37,8 +31,7 @@ export function MessageBlocks({ message }: { message: NormalizedMessage }) { const styles = ROLE_STYLES[message.role] return (
diff --git a/desktop/src/lib/touchH5.test.ts b/desktop/src/lib/touchH5.test.ts new file mode 100644 index 00000000..77911cc9 --- /dev/null +++ b/desktop/src/lib/touchH5.test.ts @@ -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 { + 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)[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;/) + }) +}) diff --git a/desktop/src/lib/touchH5.ts b/desktop/src/lib/touchH5.ts new file mode 100644 index 00000000..527830ef --- /dev/null +++ b/desktop/src/lib/touchH5.ts @@ -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 `` 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() + +/** + * 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 +} diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 7ad171c6..75bb7252 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -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() diff --git a/desktop/src/pages/TraceList.tsx b/desktop/src/pages/TraceList.tsx index f8653164..a106050b 100644 --- a/desktop/src/pages/TraceList.tsx +++ b/desktop/src/pages/TraceList.tsx @@ -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({