mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
refactor(client): simplify and cache mobile device detection
This commit is contained in:
1 parent
0727eac568
commit
dc83ed11bc
12 files changed
+95
-20
No files matched your search
@@ -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() })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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(<ModelSelect renderSlot={renderLabel} locked={false} available
|
||||
directory={createSnapshotStore(state({ groups: modelGroups(modelCount) }))}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package:
|
||||
en: 5875d977b12a64f4
|
||||
zh: a89693f28c608558
|
||||
en: cffd6fbc15f66037
|
||||
zh: 914515e7d1977c77
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: adaaabb712de1a3d
|
||||
zh: 45d6286e39880274
|
||||
|
||||
@@ -27,7 +27,7 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out
|
||||
|
||||
`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` uses the frame’s shared overlay inset, with a minimum 24px viewport margin. Its mask leaves the Windows caption unpainted while the full-viewport layer blocks background clicks. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
|
||||
|
||||
`isMobileDevice()` identifies phones and tablets for behavior such as automatic text-input focus and keyboard dismissal. It returns true for Bowser's UA classifications `mobile` and `tablet`, and for iPads reporting `MacIntel` with more than one touch point. It returns false for other devices, including Windows touchscreen computers, regardless of window width. The composer and command popups suppress automatic text-input focus on identified mobile devices; user-requested focus remains available. Browser modes or custom UA strings that conceal the mobile platform can prevent identification; unrecognized UAs return false. A desktop UA alone cannot distinguish Android tablets from Linux computers.
|
||||
`isMobileDevice()` identifies iOS and Android phones and tablets for automatic text-input focus and keyboard dismissal. It recognizes `Android`, `iPhone`, `iPad`, or `iPod` in the UA, and iPads reporting `MacIntel` with more than one touch point. Other devices, including Windows touchscreen computers, return false regardless of window width. The function caches its first result for all callers in the page; component remounts reuse that result, and reloading the page reads the browser environment again. Identified mobile devices require explicit text-input focus. Browser modes or custom UAs that conceal the mobile platform can prevent identification; unrecognized UAs return false. A desktop UA alone cannot distinguish Android tablets from Linux computers.
|
||||
|
||||
Automatic modal entry and modal/menu return focus, including after Escape and application close shortcuts, use `focusWithoutRing(element, options?)` to suppress the outline until Tab or directional navigation resumes normal focus styling. Dialog containers keep their outline-free styling. Existing borders, shadows, and error states remain intact. Mark a dialog's initial control with `data-modal-autofocus` so the modal captures its invoking control before moving focus. Controls mounted with the dialog must not use React `autoFocus`, which runs before that capture. Tab and Shift+Tab from the dialog container enter its first and last focusable controls.
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ kind: "package-library"
|
||||
|
||||
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
|
||||
|
||||
`isMobileDevice()` 识别手机和平板,供自动聚焦和收起键盘等行为使用。对于 Bowser 从 UA 识别为 `mobile` 或 `tablet` 的设备,以及报告 `MacIntel` 且触摸点数大于一的 iPad,该函数返回 true。对于其他设备(包括 Windows 触屏电脑),该函数返回 false,不受窗口宽度影响。composer 和命令弹窗在识别出的移动设备上禁止自动聚焦;用户主动请求的聚焦仍然可用。隐藏移动平台信息的浏览器模式或自定义 UA 可能导致识别失败;未识别的 UA 返回 false。仅凭桌面 UA 无法区分 Android 平板与 Linux 电脑。
|
||||
`isMobileDevice()` 识别 iOS 和 Android 手机及平板,供自动聚焦和收起键盘使用。它识别 UA 中的 `Android`、`iPhone`、`iPad` 或 `iPod`,以及报告 `MacIntel` 且触摸点数大于一的 iPad。其他设备(包括 Windows 触屏电脑)返回 false,不受窗口宽度影响。函数缓存首次判断结果,供页面内的所有调用方共享;组件重新挂载会复用该结果,重新加载页面时会再次读取浏览器环境。识别出的移动设备需要用户主动聚焦文本输入框。隐藏移动平台信息的浏览器模式或自定义 UA 可能导致识别失败;未识别的 UA 返回 false。仅凭桌面 UA 无法区分 Android 平板与 Linux 电脑。
|
||||
|
||||
弹窗自动进入及弹窗、菜单回焦,包括通过 Esc 和应用关闭快捷键触发的回焦,均使用 `focusWithoutRing(element, options?)`,在 Tab 或方向键导航恢复正常焦点样式前抑制外轮廓线。弹窗容器仍不绘制焦点外框。原有边框、阴影和错误状态保持不变。用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。
|
||||
|
||||
|
||||
@@ -48,8 +48,7 @@
|
||||
"mdast-util-math": "^3.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"micromark-util-sanitize-uri": "^2.0.1",
|
||||
"simple-icons": "16.31.0",
|
||||
"bowser": "^2.14.1"
|
||||
"simple-icons": "16.31.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
/** Mobile-device detection and automatic focus presentation. */
|
||||
import bowser from 'bowser'
|
||||
const releases = new WeakMap<HTMLElement, () => 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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(<button>Automatic destination</button>)
|
||||
const button = screen.getByRole('button')
|
||||
|
||||
@@ -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) => <TaskMenu
|
||||
open={open}
|
||||
portal
|
||||
|
||||
Generated
-3
@@ -4882,9 +4882,6 @@ importers:
|
||||
anser:
|
||||
specifier: ^2.3.5
|
||||
version: 2.3.5
|
||||
bowser:
|
||||
specifier: ^2.14.1
|
||||
version: 2.14.1
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
|
||||
Reference in new issue
Block a user