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 () => {
const React = await vi.importActual<typeof import('react')>('react')
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)
React.useImperativeHandle(ref, () => ({ open: () => setOpen(true) }), [])
return (
<>
<div data-testid="model-selector-shell" className={fluid ? 'min-w-0 flex-1' : 'shrink-0'}>
<button type="button">Model</button>
{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-panel')).toHaveClass('rounded-2xl')
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'), {
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
? '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)] ${
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 && (
<>
<div ref={plusMenuRef} className="relative">
@ -1308,7 +1311,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
)}
</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 && (
<ContextUsageIndicator
sessionId={activeTabId}
@ -1321,7 +1327,13 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
/>
)}
{!isMemberSession && activeTabId && (
<ModelSelector ref={modelSelectorRef} runtimeKey={activeTabId} disabled={isActive} compact={useCompactControls} />
<ModelSelector
ref={modelSelectorRef}
runtimeKey={activeTabId}
disabled={isActive}
compact={useCompactControls}
fluid={isMobileComposer}
/>
)}
<button
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[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', () => {

View File

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

View File

@ -41,6 +41,7 @@ type Props = {
runtimeKey?: string
disabled?: boolean
compact?: boolean
fluid?: boolean
}
export type ModelSelectorHandle = {
@ -171,6 +172,7 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
runtimeKey,
disabled = false,
compact = false,
fluid = false,
}: Props = {}, selectorRef) {
const t = useTranslation()
const isMobileBrowser = useMobileViewport() && !isDesktopRuntime()
@ -579,8 +581,11 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
: null
return (
<div className="relative min-w-0 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)] ${disabled ? 'opacity-50' : ''}`}>
<div
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
onClick={() => {
if (disabled) return
@ -591,7 +596,7 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
aria-label={buttonProviderLabel ? `${buttonModelLabel}, ${buttonProviderLabel}` : 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 ${
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)]`}>

View File

@ -28,6 +28,7 @@ vi.mock('../../i18n', () => ({
const translations: Record<string, string> = {
'sidebar.newSession': 'New Session',
'sidebar.scheduled': 'Scheduled',
'sidebar.market': 'Skills Market',
'sidebar.settings': 'Settings',
'sidebar.searchPlaceholder': 'Search sessions',
'sidebar.noSessions': 'No sessions',
@ -1207,6 +1208,7 @@ describe('Sidebar', () => {
render(<Sidebar isMobile onRequestClose={onRequestClose} />)
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()
fireEvent.click(screen.getByRole('button', { name: /Open Session/ }))
@ -1222,6 +1224,19 @@ describe('Sidebar', () => {
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', () => {
useSessionStore.setState({ isLoading: true, sessions: [] })

View File

@ -688,19 +688,21 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
{t('sidebar.scheduled')}
</NavItem>
)}
<NavItem
active={activeTabId === MARKET_TAB_ID}
collapsed={!expanded}
label={t('sidebar.market')}
touchFriendly={isMobile}
onClick={() => {
useTabStore.getState().openTab(MARKET_TAB_ID, t('sidebar.market'), 'market')
closeMobileDrawer()
}}
icon={<StorefrontIcon />}
>
{t('sidebar.market')}
</NavItem>
{!isMobile && (
<NavItem
active={activeTabId === MARKET_TAB_ID}
collapsed={!expanded}
label={t('sidebar.market')}
touchFriendly={isMobile}
onClick={() => {
useTabStore.getState().openTab(MARKET_TAB_ID, t('sidebar.market'), 'market')
closeMobileDrawer()
}}
icon={<StorefrontIcon />}
>
{t('sidebar.market')}
</NavItem>
)}
</div>
{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;/)
})
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('keeps message action bars visible in the mobile shell without relying on touch detection', () => {
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', () => {

View File

@ -678,7 +678,7 @@ export function ActiveSession() {
{historyError}
</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;
}
/* Message action bars (copy / branch) are hover-revealed on desktop; touch
devices have no hover, so keep them always visible there. */
/* Message action bars (copy / branch) are hover-revealed on desktop. Keep
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] {
opacity: 1;
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;
components carry their own pressed/hover styling. */
html[data-touch-h5],