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({