mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
fix(desktop): improve H5 mobile interactions #1020
This commit is contained in:
parent
d587ddbb19
commit
254e5fcfb1
@ -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 },
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)]`}>
|
||||||
|
|||||||
@ -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: [] })
|
||||||
|
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -678,7 +678,7 @@ export function ActiveSession() {
|
|||||||
{historyError}
|
{historyError}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<MessageList compact={showRightPanel} />
|
<MessageList compact={showRightPanel} mobileLayout={isMobileLayout} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user