mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
The browser H5 surface now switches to a phone-oriented shell: the sidebar becomes a closed-by-default drawer, chat stays primary, workspace and terminal panels stay off the mobile chat surface, composer controls use larger touch targets, and mobile menus avoid desktop-only widths and keyboard hints. Desktop and Tauri sidebar behavior remain on the existing store-driven path. Constraint: H5 is personal/team browser access layered on the existing desktop web UI, so the normal desktop app must keep its current layout behavior. Rejected: Share the global sidebarOpen default for mobile first paint | it can flash the drawer open before effects run on a phone. Confidence: high Scope-risk: moderate Directive: Keep mobile-only layout branching behind useMobileViewport() && !isTauriRuntime() unless a future task explicitly redesigns the native desktop shell. Tested: cd desktop && bunx vitest run src/hooks/useMobileViewport.test.tsx src/components/layout/AppShell.test.tsx src/components/layout/Sidebar.test.tsx src/pages/ActiveSession.test.tsx src/components/chat/ChatInput.test.tsx src/components/controls/PermissionModeSelector.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build
121 lines
3.6 KiB
TypeScript
121 lines
3.6 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { act, cleanup, render, screen } from '@testing-library/react'
|
|
import '@testing-library/jest-dom'
|
|
import { useMobileViewport } from './useMobileViewport'
|
|
|
|
function Probe() {
|
|
const isMobile = useMobileViewport()
|
|
return <div data-testid="viewport-state">{isMobile ? 'mobile' : 'desktop'}</div>
|
|
}
|
|
|
|
function createMatchMediaController(initialMatches = false, legacy = false) {
|
|
let matches = initialMatches
|
|
const listeners = new Set<(event: MediaQueryListEvent) => void>()
|
|
const addListener = vi.fn((listener: (event: MediaQueryListEvent) => void) => {
|
|
listeners.add(listener)
|
|
})
|
|
const removeListener = vi.fn((listener: (event: MediaQueryListEvent) => void) => {
|
|
listeners.delete(listener)
|
|
})
|
|
|
|
const matchMedia = vi.fn().mockImplementation((query: string) => ({
|
|
matches,
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: legacy ? undefined : vi.fn((type: string, listener: (event: MediaQueryListEvent) => void) => {
|
|
if (type === 'change') listeners.add(listener)
|
|
}),
|
|
removeEventListener: legacy ? undefined : vi.fn((type: string, listener: (event: MediaQueryListEvent) => void) => {
|
|
if (type === 'change') listeners.delete(listener)
|
|
}),
|
|
addListener,
|
|
removeListener,
|
|
dispatchEvent: vi.fn(),
|
|
}))
|
|
|
|
return {
|
|
matchMedia,
|
|
addListener,
|
|
removeListener,
|
|
emit(nextMatches: boolean) {
|
|
matches = nextMatches
|
|
const event = { matches: nextMatches, media: '(max-width: 767px)' } as MediaQueryListEvent
|
|
listeners.forEach((listener) => listener(event))
|
|
},
|
|
getListenerCount() {
|
|
return listeners.size
|
|
},
|
|
}
|
|
}
|
|
|
|
describe('useMobileViewport', () => {
|
|
const originalMatchMedia = window.matchMedia
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
if (originalMatchMedia) {
|
|
window.matchMedia = originalMatchMedia
|
|
} else {
|
|
Reflect.deleteProperty(window, 'matchMedia')
|
|
}
|
|
})
|
|
|
|
it('defaults to desktop when matchMedia is unavailable', () => {
|
|
Reflect.deleteProperty(window, 'matchMedia')
|
|
|
|
render(<Probe />)
|
|
|
|
expect(screen.getByTestId('viewport-state')).toHaveTextContent('desktop')
|
|
})
|
|
|
|
it('tracks viewport changes and removes the listener on cleanup', () => {
|
|
const controller = createMatchMediaController(false)
|
|
window.matchMedia = controller.matchMedia as typeof window.matchMedia
|
|
|
|
const { unmount } = render(<Probe />)
|
|
|
|
expect(screen.getByTestId('viewport-state')).toHaveTextContent('desktop')
|
|
expect(controller.matchMedia).toHaveBeenCalledWith('(max-width: 767px)')
|
|
expect(controller.getListenerCount()).toBe(1)
|
|
|
|
act(() => {
|
|
controller.emit(true)
|
|
})
|
|
|
|
expect(screen.getByTestId('viewport-state')).toHaveTextContent('mobile')
|
|
|
|
unmount()
|
|
|
|
expect(controller.getListenerCount()).toBe(0)
|
|
})
|
|
|
|
it('reads the initial media query before first paint', () => {
|
|
const controller = createMatchMediaController(true)
|
|
window.matchMedia = controller.matchMedia as typeof window.matchMedia
|
|
|
|
render(<Probe />)
|
|
|
|
expect(screen.getByTestId('viewport-state')).toHaveTextContent('mobile')
|
|
})
|
|
|
|
it('falls back to legacy media query listeners', () => {
|
|
const controller = createMatchMediaController(false, true)
|
|
window.matchMedia = controller.matchMedia as typeof window.matchMedia
|
|
|
|
const { unmount } = render(<Probe />)
|
|
|
|
expect(controller.addListener).toHaveBeenCalledTimes(1)
|
|
|
|
act(() => {
|
|
controller.emit(true)
|
|
})
|
|
|
|
expect(screen.getByTestId('viewport-state')).toHaveTextContent('mobile')
|
|
|
|
unmount()
|
|
|
|
expect(controller.removeListener).toHaveBeenCalledTimes(1)
|
|
expect(controller.getListenerCount()).toBe(0)
|
|
})
|
|
})
|