fix(desktop): improve H5 mobile interactions #1020

This commit is contained in:
程序员阿江(Relakkes) 2026-07-14 17:51:49 +08:00
parent d587ddbb19
commit 254e5fcfb1
10 changed files with 106 additions and 29 deletions

View File

@ -72,14 +72,14 @@ vi.mock('../controls/PermissionModeSelector', () => ({
vi.mock('../controls/ModelSelector', async () => { vi.mock('../controls/ModelSelector', async () => {
const React = await vi.importActual<typeof import('react')>('react') const React = await vi.importActual<typeof import('react')>('react')
return { return {
ModelSelector: React.forwardRef<{ open: () => void }, Record<string, never>>((_props, ref) => { ModelSelector: React.forwardRef<{ open: () => void }, { fluid?: boolean }>(({ fluid }, ref) => {
const [open, setOpen] = React.useState(false) const [open, setOpen] = React.useState(false)
React.useImperativeHandle(ref, () => ({ open: () => setOpen(true) }), []) React.useImperativeHandle(ref, () => ({ open: () => setOpen(true) }), [])
return ( return (
<> <div data-testid="model-selector-shell" className={fluid ? 'min-w-0 flex-1' : 'shrink-0'}>
<button type="button">Model</button> <button type="button">Model</button>
{open && <div data-testid="model-selector-dropdown">Model selector opened</div>} {open && <div data-testid="model-selector-dropdown">Model selector opened</div>}
</> </div>
) )
}), }),
} }
@ -1380,6 +1380,9 @@ describe('ChatInput file mentions', () => {
expect(screen.getByTestId('chat-input-shell').className).toContain('safe-area-inset-bottom') expect(screen.getByTestId('chat-input-shell').className).toContain('safe-area-inset-bottom')
expect(screen.getByTestId('chat-input-panel')).toHaveClass('rounded-2xl') expect(screen.getByTestId('chat-input-panel')).toHaveClass('rounded-2xl')
expect(screen.getByTestId('chat-input-panel')).not.toHaveClass('rounded-b-none') expect(screen.getByTestId('chat-input-panel')).not.toHaveClass('rounded-b-none')
expect(screen.getByTestId('chat-input-toolbar-leading')).toHaveClass('shrink-0', 'gap-1')
expect(screen.getByTestId('chat-input-toolbar-trailing')).toHaveClass('min-w-0', 'flex-1', 'justify-end', 'gap-1')
expect(screen.getByTestId('model-selector-shell')).toHaveClass('min-w-0', 'flex-1')
fireEvent.change(screen.getByRole('textbox'), { fireEvent.change(screen.getByRole('textbox'), {
target: { value: '@cond', selectionStart: 5 }, target: { value: '@cond', selectionStart: 5 },

View File

@ -1269,9 +1269,12 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
<div data-testid="chat-input-toolbar" className={isHeroComposer <div data-testid="chat-input-toolbar" className={isHeroComposer
? 'flex items-center justify-between border-t border-[var(--color-border-separator)] pt-3' ? 'flex items-center justify-between border-t border-[var(--color-border-separator)] pt-3'
: `mt-2 flex items-center justify-between border-t border-[var(--color-border-separator)] ${ : `mt-2 flex items-center justify-between border-t border-[var(--color-border-separator)] ${
useCompactControls ? '-mx-3 -mb-3 gap-2 px-2.5 py-2' : '-mx-4 -mb-4 px-3 py-3' useCompactControls ? `-mx-3 -mb-3 px-2.5 py-2 ${isMobileComposer ? 'gap-1' : 'gap-2'}` : '-mx-4 -mb-4 px-3 py-3'
}`}> }`}>
<div className="flex min-w-0 items-center gap-2"> <div
data-testid="chat-input-toolbar-leading"
className={`flex min-w-0 items-center ${isMobileComposer ? 'shrink-0 gap-1' : 'gap-2'}`}
>
{!isMemberSession && ( {!isMemberSession && (
<> <>
<div ref={plusMenuRef} className="relative"> <div ref={plusMenuRef} className="relative">
@ -1308,7 +1311,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
)} )}
</div> </div>
<div className="flex min-w-0 items-center gap-2"> <div
data-testid="chat-input-toolbar-trailing"
className={`flex min-w-0 items-center ${isMobileComposer ? 'flex-1 justify-end gap-1' : 'gap-2'}`}
>
{!isMemberSession && activeTabId && ( {!isMemberSession && activeTabId && (
<ContextUsageIndicator <ContextUsageIndicator
sessionId={activeTabId} sessionId={activeTabId}
@ -1321,7 +1327,13 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
/> />
)} )}
{!isMemberSession && activeTabId && ( {!isMemberSession && activeTabId && (
<ModelSelector ref={modelSelectorRef} runtimeKey={activeTabId} disabled={isActive} compact={useCompactControls} /> <ModelSelector
ref={modelSelectorRef}
runtimeKey={activeTabId}
disabled={isActive}
compact={useCompactControls}
fluid={isMobileComposer}
/>
)} )}
<button <button
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit} onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}

View File

@ -5403,6 +5403,27 @@ describe('conversation navigation layout', () => {
expect(getConversationNavigationTargetScrollTop(items[0]!, offsets, 400, 650)).toBe(0) expect(getConversationNavigationTargetScrollTop(items[0]!, offsets, 400, 650)).toBe(0)
expect(getConversationNavigationTargetScrollTop(items[2]!, offsets, 400, 650)).toBe(250) expect(getConversationNavigationTargetScrollTop(items[2]!, offsets, 400, 650)).toBe(250)
}) })
it('does not render the desktop conversation rail in the mobile chat layout', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{ id: 'user-1', type: 'user_text', content: 'First prompt', timestamp: 1 },
{ id: 'assistant-1', type: 'assistant_text', content: 'First reply', timestamp: 2 },
{ id: 'user-2', type: 'user_text', content: 'Second prompt', timestamp: 3 },
{ id: 'assistant-2', type: 'assistant_text', content: 'Second reply', timestamp: 4 },
],
}),
},
})
const { rerender } = render(<MessageList />)
expect(screen.getByRole('navigation', { name: 'Conversation navigation' })).toBeTruthy()
rerender(<MessageList mobileLayout />)
expect(screen.queryByRole('navigation', { name: 'Conversation navigation' })).toBeNull()
})
}) })
describe('workspace panel origin visibility', () => { describe('workspace panel origin visibility', () => {

View File

@ -932,6 +932,7 @@ function MemoryEventCard({ message }: { message: MemoryEvent }) {
type MessageListProps = { type MessageListProps = {
sessionId?: string | null sessionId?: string | null
compact?: boolean compact?: boolean
mobileLayout?: boolean
} }
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48 const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
@ -1428,7 +1429,7 @@ const MeasuredRenderItem = memo(function MeasuredRenderItem({
) )
}) })
export function MessageList({ sessionId, compact = false }: MessageListProps = {}) { export function MessageList({ sessionId, compact = false, mobileLayout = false }: MessageListProps = {}) {
const activeTabId = useTabStore((s) => s.activeTabId) const activeTabId = useTabStore((s) => s.activeTabId)
const resolvedSessionId = sessionId ?? activeTabId const resolvedSessionId = sessionId ?? activeTabId
const isWorkspacePanelOpen = useWorkspacePanelStore((state) => const isWorkspacePanelOpen = useWorkspacePanelStore((state) =>
@ -1953,6 +1954,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
? 'compact' ? 'compact'
: 'edge' : 'edge'
const showConversationNavigator = const showConversationNavigator =
!mobileLayout &&
!isTouchH5Document() && !isTouchH5Document() &&
conversationNavigationItems.length >= CONVERSATION_NAVIGATION_MIN_ITEMS conversationNavigationItems.length >= CONVERSATION_NAVIGATION_MIN_ITEMS
const chatScrollPaddingClass = compact const chatScrollPaddingClass = compact

View File

@ -41,6 +41,7 @@ type Props = {
runtimeKey?: string runtimeKey?: string
disabled?: boolean disabled?: boolean
compact?: boolean compact?: boolean
fluid?: boolean
} }
export type ModelSelectorHandle = { export type ModelSelectorHandle = {
@ -171,6 +172,7 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
runtimeKey, runtimeKey,
disabled = false, disabled = false,
compact = false, compact = false,
fluid = false,
}: Props = {}, selectorRef) { }: Props = {}, selectorRef) {
const t = useTranslation() const t = useTranslation()
const isMobileBrowser = useMobileViewport() && !isDesktopRuntime() const isMobileBrowser = useMobileViewport() && !isDesktopRuntime()
@ -579,8 +581,11 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
: null : null
return ( return (
<div className="relative min-w-0 shrink-0"> <div
<div ref={ref} className={`flex min-w-0 items-stretch rounded-full bg-[var(--color-surface-container-low)] transition-colors hover:bg-[var(--color-surface-hover)] ${disabled ? 'opacity-50' : ''}`}> data-testid="model-selector-shell"
className={`relative min-w-0 ${fluid ? 'flex-1' : 'shrink-0'}`}
>
<div ref={ref} className={`flex min-w-0 items-stretch rounded-full bg-[var(--color-surface-container-low)] transition-colors hover:bg-[var(--color-surface-hover)] ${fluid ? 'w-full' : ''} ${disabled ? 'opacity-50' : ''}`}>
<button <button
onClick={() => { onClick={() => {
if (disabled) return if (disabled) return
@ -591,7 +596,7 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
aria-label={buttonProviderLabel ? `${buttonModelLabel}, ${buttonProviderLabel}` : undefined} aria-label={buttonProviderLabel ? `${buttonModelLabel}, ${buttonProviderLabel}` : undefined}
title={buttonProviderLabel ? `${buttonProviderLabel} · ${buttonModelLabel}` : undefined} title={buttonProviderLabel ? `${buttonProviderLabel} · ${buttonModelLabel}` : undefined}
className={`flex min-w-0 items-center gap-2 rounded-l-full text-xs font-medium text-[var(--color-text-secondary)] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] disabled:cursor-not-allowed ${ className={`flex min-w-0 items-center gap-2 rounded-l-full text-xs font-medium text-[var(--color-text-secondary)] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] disabled:cursor-not-allowed ${
compact ? 'max-w-[112px] py-1.5 pl-2.5 pr-1' : 'max-w-[220px] py-1.5 pl-3 pr-1' compact ? `${fluid ? 'flex-1' : ''} max-w-[112px] py-1.5 pl-2.5 pr-1` : 'max-w-[220px] py-1.5 pl-3 pr-1'
}`} }`}
> >
<span className={`${compact ? 'text-xs' : 'text-sm'} min-w-0 flex-1 truncate font-semibold text-[var(--color-text-primary)]`}> <span className={`${compact ? 'text-xs' : 'text-sm'} min-w-0 flex-1 truncate font-semibold text-[var(--color-text-primary)]`}>

View File

@ -28,6 +28,7 @@ vi.mock('../../i18n', () => ({
const translations: Record<string, string> = { const translations: Record<string, string> = {
'sidebar.newSession': 'New Session', 'sidebar.newSession': 'New Session',
'sidebar.scheduled': 'Scheduled', 'sidebar.scheduled': 'Scheduled',
'sidebar.market': 'Skills Market',
'sidebar.settings': 'Settings', 'sidebar.settings': 'Settings',
'sidebar.searchPlaceholder': 'Search sessions', 'sidebar.searchPlaceholder': 'Search sessions',
'sidebar.noSessions': 'No sessions', 'sidebar.noSessions': 'No sessions',
@ -1207,6 +1208,7 @@ describe('Sidebar', () => {
render(<Sidebar isMobile onRequestClose={onRequestClose} />) render(<Sidebar isMobile onRequestClose={onRequestClose} />)
expect(screen.queryByRole('button', { name: 'Scheduled' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Scheduled' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Skills Market' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Open Session/ })) fireEvent.click(screen.getByRole('button', { name: /Open Session/ }))
@ -1222,6 +1224,19 @@ describe('Sidebar', () => {
expect(onRequestClose).toHaveBeenCalledTimes(2) expect(onRequestClose).toHaveBeenCalledTimes(2)
}) })
it('keeps the market entry available in desktop navigation', () => {
render(<Sidebar />)
fireEvent.click(screen.getByRole('button', { name: 'Skills Market' }))
expect(useTabStore.getState().activeTabId).toBe('__market__')
expect(useTabStore.getState().tabs).toEqual(
expect.arrayContaining([
expect.objectContaining({ sessionId: '__market__', title: 'Skills Market', type: 'market' }),
]),
)
})
it('shows a loading state instead of an empty session list while initial fetch is pending', () => { it('shows a loading state instead of an empty session list while initial fetch is pending', () => {
useSessionStore.setState({ isLoading: true, sessions: [] }) useSessionStore.setState({ isLoading: true, sessions: [] })

View File

@ -688,19 +688,21 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
{t('sidebar.scheduled')} {t('sidebar.scheduled')}
</NavItem> </NavItem>
)} )}
<NavItem {!isMobile && (
active={activeTabId === MARKET_TAB_ID} <NavItem
collapsed={!expanded} active={activeTabId === MARKET_TAB_ID}
label={t('sidebar.market')} collapsed={!expanded}
touchFriendly={isMobile} label={t('sidebar.market')}
onClick={() => { touchFriendly={isMobile}
useTabStore.getState().openTab(MARKET_TAB_ID, t('sidebar.market'), 'market') onClick={() => {
closeMobileDrawer() useTabStore.getState().openTab(MARKET_TAB_ID, t('sidebar.market'), 'market')
}} closeMobileDrawer()
icon={<StorefrontIcon />} }}
> icon={<StorefrontIcon />}
{t('sidebar.market')} >
</NavItem> {t('sidebar.market')}
</NavItem>
)}
</div> </div>
{expanded ? ( {expanded ? (

View File

@ -419,8 +419,9 @@ describe('touch-H5 stylesheet contract', () => {
expect(css).toMatch(/html\[data-touch-h5\]\[data-touch-h5-keyboard\] \.app-shell-viewport \{\s*\n\s*padding-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', () => { it('keeps message action bars visible in the mobile shell without relying on touch detection', () => {
expect(css).toMatch(/html\[data-touch-h5\] \[data-message-actions\] \{\s*\n\s*opacity: 1;\s*\n\s*pointer-events: auto;/) expect(css).toMatch(/\.app-shell--mobile \[data-message-actions\],\s*\nhtml\[data-touch-h5\] \[data-message-actions\] \{\s*\n\s*opacity: 1;\s*\n\s*pointer-events: auto;/)
expect(css).toMatch(/\.app-shell--mobile \[data-message-actions\] button \{\s*\n\s*width: 2\.5rem;/)
}) })
it('disables paint skipping for the trace-window rows too', () => { it('disables paint skipping for the trace-window rows too', () => {

View File

@ -678,7 +678,7 @@ export function ActiveSession() {
{historyError} {historyError}
</div> </div>
) : ( ) : (
<MessageList compact={showRightPanel} /> <MessageList compact={showRightPanel} mobileLayout={isMobileLayout} />
)} )}
</> </>
)} )}

View File

@ -1610,13 +1610,29 @@ html[data-touch-h5][data-touch-h5-keyboard] .app-shell-viewport {
padding-bottom: 0px; padding-bottom: 0px;
} }
/* Message action bars (copy / branch) are hover-revealed on desktop; touch /* Message action bars (copy / branch) are hover-revealed on desktop. Keep
devices have no hover, so keep them always visible there. */ them visible whenever the browser is in the narrow H5 shell, even if an
embedded browser reports no coarse pointer, and retain the touch marker as
coverage for wider tablets. */
.app-shell--mobile [data-message-actions],
html[data-touch-h5] [data-message-actions] { html[data-touch-h5] [data-message-actions] {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
.app-shell--mobile [data-message-actions] {
height: 2.5rem;
}
.app-shell--mobile [data-message-actions] > div,
.app-shell--mobile [data-message-actions] button {
min-height: 2.5rem;
}
.app-shell--mobile [data-message-actions] button {
width: 2.5rem;
}
/* Kill the gray flash WKWebView paints over any tapped interactive element; /* Kill the gray flash WKWebView paints over any tapped interactive element;
components carry their own pressed/hover styling. */ components carry their own pressed/hover styling. */
html[data-touch-h5], html[data-touch-h5],