diff --git a/packages/client/ui-commands/tests/popup-view.client.spec.tsx b/packages/client/ui-commands/tests/popup-view.client.spec.tsx index 387108f2ed..9b1d246927 100644 --- a/packages/client/ui-commands/tests/popup-view.client.spec.tsx +++ b/packages/client/ui-commands/tests/popup-view.client.spec.tsx @@ -10,6 +10,7 @@ * retry button, the highlighted row scrolls into view, and the card height * clamps to the space above the composer. */ +import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SelectOption } from '../src/client/contract.ts' @@ -157,6 +158,10 @@ describe('PopupSelectView', () => { else Object.defineProperty(navigator, name, descriptor) }) } + vi.resetModules() + const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts') + const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice) + onTestFinished(() => { device.mockRestore() }) const { popup, search } = await mountOpen() expect(document.activeElement === search).toBe(automatic) act(() => { search.focus() }) diff --git a/packages/client/ui-conversation/tests/editor-focus.client.spec.ts b/packages/client/ui-conversation/tests/editor-focus.client.spec.ts index 1dc79ec895..0dd714c9a1 100644 --- a/packages/client/ui-conversation/tests/editor-focus.client.spec.ts +++ b/packages/client/ui-conversation/tests/editor-focus.client.spec.ts @@ -1,5 +1,6 @@ // @vitest-environment jsdom /** Composer selection survives focus transfers and background editor reconciliation. */ +import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' import { expect, it, onTestFinished, vi } from 'vitest' import { $getRoot, $getSelection, $isRangeSelection, SELECTION_CHANGE_COMMAND, UNDO_COMMAND } from 'lexical' import { DraftEditorRuntime } from '../src/client/input/editor/runtime.ts' @@ -140,9 +141,13 @@ it('restores a pending selection when blur and explicit focus share one update b expect(reveal).toHaveBeenCalledOnce() }) -it('hands mobile submission focus to the composer without stealing focus from another control', () => { +it('hands mobile submission focus to the composer without stealing focus from another control', async () => { const userAgent = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1') onTestFinished(() => { userAgent.mockRestore() }) + vi.resetModules() + const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts') + const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice) + onTestFinished(() => { device.mockRestore() }) const f = fixture() const dispose = installDraftKeyboardDismissal(f.editor, { current: f.card }) onTestFinished(dispose) @@ -157,9 +162,13 @@ it('hands mobile submission focus to the composer without stealing focus from an expect(document.activeElement).toBe(f.outside) }) -it('retains mobile focus for a newer draft suffix and removes dismissal with the view', () => { +it('retains mobile focus for a newer draft suffix and removes dismissal with the view', async () => { const userAgent = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1') onTestFinished(() => { userAgent.mockRestore() }) + vi.resetModules() + const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts') + const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice) + onTestFinished(() => { device.mockRestore() }) const f = fixture() const dispose = installDraftKeyboardDismissal(f.editor, { current: f.card }) onTestFinished(dispose) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 7cd23b3d4e..e3c0e1b11e 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -8,6 +8,7 @@ // root listener routes them through the keymap commands); draft writes drive // the shell (jsdom's beforeinput lacks the ranges Lexical needs). +import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' import './control-row-dom.ts' import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' @@ -1431,6 +1432,10 @@ describe('running and lock semantics', () => { else Object.defineProperty(navigator, name, descriptor) }) } + vi.resetModules() + const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts') + const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice) + onTestFinished(() => { device.mockRestore() }) const outside = document.createElement('input') document.body.append(outside) onTestFinished(() => { outside.remove() }) diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 336c12c97d..bb4832a0ce 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' @@ -471,6 +472,10 @@ describe('ModelSelect keyboard walk', () => { else Object.defineProperty(navigator, name, descriptor) }) } + vi.resetModules() + const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts') + const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice) + onTestFinished(() => { device.mockRestore() }) const select = vi.fn() render( void>() const navigationKeys = new Set(['Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End']) +let mobileDevice: boolean | undefined /** - * Identify phones and tablets from the current browser's device information. - * Bowser identifies phones and tablets from the UA; MacIntel with multiple touch points identifies desktop-mode iPads. - * @returns true for identified phones and tablets; false for other devices, including unrecognized UAs. + * Identify iOS and Android phones and tablets from the current browser's device information. + * MacIntel with multiple touch points identifies desktop-mode iPads. + * The first result is shared by all callers until the page reloads. + * @returns true for recognized mobile devices; false for other devices, including unrecognized UAs. */ export function isMobileDevice(): boolean { - const platformType = bowser.parse(navigator.userAgent).platform.type - const desktopIPad = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1 - return platformType === 'mobile' || platformType === 'tablet' || desktopIPad + if (mobileDevice !== undefined) return mobileDevice + const { userAgent, platform, maxTouchPoints } = navigator + mobileDevice = /Android|iPhone|iPad|iPod/i.test(userAgent) + || (platform === 'MacIntel' && maxTouchPoints > 1) + return mobileDevice } /** diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index d03035ee29..3f56fda81c 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { createRef, useState } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { Button, ConnectionIndicator, Input, Menu, MenuItemButton, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives' import { POINTER_GRACE_MS } from '../src/pointer-grace.ts' import { focusWithoutRing } from '../src/focus.ts' @@ -9,6 +9,53 @@ import buttonCss from '../src/Button.module.css' afterEach(cleanup) +describe('isMobileDevice', () => { + beforeEach(() => { vi.resetModules() }) + + it.each([ + { name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X)', platform: 'iPhone', maxTouchPoints: 5, mobile: true }, + { name: 'iPad', userAgent: 'Mozilla/5.0 (iPad; CPU OS 18_6 like Mac OS X)', platform: 'iPad', maxTouchPoints: 5, mobile: true }, + { name: 'iPod', userAgent: 'Mozilla/5.0 (iPod; U; CPU like Mac OS X; en)', platform: 'iPod', maxTouchPoints: 5, mobile: true }, + { name: 'Android phone', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) Mobile', platform: 'Linux armv8l', maxTouchPoints: 0, mobile: true }, + { name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K)', platform: 'Linux armv8l', maxTouchPoints: 5, mobile: true }, + { name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', platform: 'MacIntel', maxTouchPoints: 5, mobile: true }, + { name: 'Mac', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', platform: 'MacIntel', maxTouchPoints: 0, mobile: false }, + { name: 'Windows touchscreen', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', platform: 'Win32', maxTouchPoints: 5, mobile: false }, + { name: 'native networking UA', userAgent: 'DeepSeek%20Chat/0 CFNetwork/3860.400.51 Darwin/25.3.0', platform: '', maxTouchPoints: 0, mobile: false }, + { name: 'unknown UA', userAgent: 'Unrecognized/1.0', platform: '', maxTouchPoints: 0, mobile: false }, + ])('classifies $name', async ({ userAgent, platform, maxTouchPoints, mobile }) => { + for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) { + const descriptor = Object.getOwnPropertyDescriptor(navigator, name) + Object.defineProperty(navigator, name, { configurable: true, value }) + onTestFinished(() => { + if (descriptor === undefined) Reflect.deleteProperty(navigator, name) + else Object.defineProperty(navigator, name, descriptor) + }) + } + const { isMobileDevice } = await import('../src/focus.ts') + expect(isMobileDevice()).toBe(mobile) + }) + + it.each([true, false])('shares a cached %s result between callers until the module reloads', async (mobile) => { + const mobileUA = 'Mozilla/5.0 (iPhone)' + const desktopUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' + const ua = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(mobile ? mobileUA : desktopUA) + onTestFinished(() => { ua.mockRestore() }) + const first = await import('../src/focus.ts') + expect(first.isMobileDevice()).toBe(mobile) + + ua.mockReturnValue(mobile ? desktopUA : mobileUA) + const other = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts') + expect(other.isMobileDevice()).toBe(mobile) + expect(ua).toHaveBeenCalledTimes(1) + + vi.resetModules() + const reloaded = await import('../src/focus.ts') + expect(reloaded.isMobileDevice()).toBe(!mobile) + expect(ua).toHaveBeenCalledTimes(2) + }) +}) + it('retains automatic focus styling while typing and restores it for keyboard navigation', () => { render() const button = screen.getByRole('button') diff --git a/packages/client/ui-schedule/tests/task-menu.client.spec.tsx b/packages/client/ui-schedule/tests/task-menu.client.spec.tsx index f503d6a04e..9097bcec30 100644 --- a/packages/client/ui-schedule/tests/task-menu.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-menu.client.spec.tsx @@ -4,6 +4,7 @@ * handover a portaled list performs once it is placed, the row walk, and the * dismissal and focus-return paths the task manager's four menus rely on. */ +import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' import { useState } from 'react' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' @@ -146,7 +147,7 @@ describe('TaskMenu', () => { { name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5 }, { name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 5 }, { name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 5 }, - ])('leaves header search focus to the user on $name', ({ userAgent, platform, maxTouchPoints }) => { + ])('leaves header search focus to the user on $name', async ({ userAgent, platform, maxTouchPoints }) => { for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) { const descriptor = Object.getOwnPropertyDescriptor(navigator, name) Object.defineProperty(navigator, name, { configurable: true, value }) @@ -155,6 +156,10 @@ describe('TaskMenu', () => { else Object.defineProperty(navigator, name, descriptor) }) } + vi.resetModules() + const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts') + const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice) + onTestFinished(() => { device.mockRestore() }) const menu = (open: boolean) =>