mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +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 () => {
|
||||
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 },
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)]`}>
|
||||
|
||||
@ -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: [] })
|
||||
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -678,7 +678,7 @@ export function ActiveSession() {
|
||||
{historyError}
|
||||
</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;
|
||||
}
|
||||
|
||||
/* 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],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user