mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix(desktop): reclaim H5 chat viewport space
The mobile H5 shell was spending one row on the drawer button and another row on session metadata, which left less space for the actual chat on phones. The mobile shell now shares the drawer row with the active session title and metadata, while the session body suppresses its duplicate header on H5. The mobile composer also restores horizontal breathing room and safe-area bottom padding so the input panel no longer sits flush against the viewport edge. Constraint: Keep desktop/Tauri layout unchanged and scope the change to browser H5 mobile behavior. Rejected: Keep the separate mobile menu row | it preserves the exact waste reported in phone screenshots. Confidence: high Scope-risk: narrow Directive: Do not reintroduce a second mobile session title row without checking a phone-sized H5 viewport. Tested: cd desktop && bun run test src/components/layout/AppShell.test.tsx src/pages/ActiveSession.test.tsx src/components/chat/ChatInput.test.tsx --run Tested: git diff --check Tested: agent-browser 393x852 H5 layout smoke with screenshot /tmp/h5-layout-verify/mobile-h5-layout.png Not-tested: cd desktop && bun run lint, blocked by pre-existing Settings.tsx responseLanguage/responseLangDraft errors outside this change
This commit is contained in:
parent
99c964e0ca
commit
a00f1882ab
@ -462,6 +462,10 @@ describe('ChatInput file mentions', () => {
|
||||
expect(screen.getByRole('button', { name: 'Open composer tools' })).toHaveClass('h-11', 'w-11')
|
||||
expect(screen.getByRole('button', { name: 'Run' })).toHaveClass('h-11', 'w-11')
|
||||
expect(screen.queryByText('Run')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('chat-input-shell')).toHaveClass('px-3')
|
||||
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')
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: '@cond', selectionStart: 5 },
|
||||
|
||||
@ -721,12 +721,13 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-input-shell"
|
||||
className={
|
||||
isHeroComposer
|
||||
? `bg-[var(--color-surface)] ${isMobileComposer ? 'px-4 pb-3' : 'px-8 pb-4'}`
|
||||
: compact
|
||||
? `border-t border-[var(--color-border)]/70 bg-[var(--color-surface)] ${isMobileComposer ? 'px-0 pb-0 pt-2' : 'px-3 py-3'}`
|
||||
: `bg-[var(--color-surface)] ${isMobileComposer ? 'px-0 pb-0 pt-2' : 'px-4 py-4'}`
|
||||
? `border-t border-[var(--color-border)]/70 bg-[var(--color-surface)] ${isMobileComposer ? 'px-3 pb-[calc(env(safe-area-inset-bottom)+10px)] pt-2' : 'px-3 py-3'}`
|
||||
: `bg-[var(--color-surface)] ${isMobileComposer ? 'px-3 pb-[calc(env(safe-area-inset-bottom)+10px)] pt-2' : 'px-4 py-4'}`
|
||||
}
|
||||
>
|
||||
<div
|
||||
@ -739,11 +740,12 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-testid="chat-input-panel"
|
||||
className={isHeroComposer
|
||||
? 'glass-panel relative flex flex-col gap-3 rounded-t-xl rounded-b-none p-4 transition-colors'
|
||||
: compact
|
||||
? `glass-panel relative p-3 transition-colors ${isMobileComposer ? 'rounded-t-2xl rounded-b-none shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl'}`
|
||||
: `glass-panel relative transition-colors ${isMobileComposer ? 'rounded-t-2xl rounded-b-none p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl p-4'}`}
|
||||
? `glass-panel relative p-3 transition-colors ${isMobileComposer ? 'rounded-2xl shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl'}`
|
||||
: `glass-panel relative transition-colors ${isMobileComposer ? 'rounded-2xl p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl p-4'}`}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
|
||||
@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
initializeDesktopServerUrl: vi.fn(),
|
||||
@ -120,6 +121,7 @@ describe('AppShell boot flow', () => {
|
||||
})
|
||||
mocks.tabState.activeTabId = null
|
||||
mocks.tabState.tabs = []
|
||||
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
|
||||
useUIStore.setState({ sidebarOpen: true })
|
||||
})
|
||||
|
||||
@ -271,6 +273,39 @@ describe('AppShell boot flow', () => {
|
||||
expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('data-state', 'closed')
|
||||
})
|
||||
|
||||
it('shares the mobile drawer row with the active session title', async () => {
|
||||
mocks.isMobile = true
|
||||
mocks.tabState.activeTabId = 'session-mobile'
|
||||
mocks.tabState.tabs = [
|
||||
{ sessionId: 'session-mobile', title: 'Fallback tab title', type: 'session', status: 'running' },
|
||||
]
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: 'session-mobile',
|
||||
title: 'Analyze recent commits',
|
||||
createdAt: '2026-05-10T00:00:00.000Z',
|
||||
modifiedAt: new Date().toISOString(),
|
||||
messageCount: 7,
|
||||
projectPath: '/tmp/project',
|
||||
workDir: '/tmp/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: 'session-mobile',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<AppShell />)
|
||||
|
||||
await screen.findByText('content loaded')
|
||||
|
||||
const header = screen.getByTestId('mobile-session-header')
|
||||
expect(header).toHaveTextContent('Analyze recent commits')
|
||||
expect(header).toHaveTextContent('session.active')
|
||||
expect(header).toHaveTextContent('session.messages')
|
||||
expect(screen.getByTestId('mobile-sidebar-toggle')).toHaveClass('h-10', 'w-10')
|
||||
})
|
||||
|
||||
it('keeps browser H5 mobile on chat tabs when settings was restored as active', async () => {
|
||||
mocks.isMobile = true
|
||||
mocks.tabState.activeTabId = '__settings__'
|
||||
|
||||
@ -16,6 +16,7 @@ import { TabBar } from './TabBar'
|
||||
import { StartupErrorView } from './StartupErrorView'
|
||||
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { H5ConnectionView } from './H5ConnectionView'
|
||||
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
||||
@ -41,8 +42,22 @@ export function AppShell() {
|
||||
const tabs = useTabStore((s) => s.tabs)
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab)
|
||||
const activeSession = useSessionStore((s) =>
|
||||
activeTabId ? s.sessions.find((session) => session.id === activeTabId) ?? null : null,
|
||||
)
|
||||
const wasMobileShellRef = useRef(false)
|
||||
const effectiveSidebarOpen = isMobileShell ? mobileSidebarOpen : sidebarOpen
|
||||
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId)
|
||||
const isActiveChatTab = isChatTab(activeTab)
|
||||
const mobileSessionTitle = activeSession?.title || activeTab?.title || t('session.untitled')
|
||||
const mobileSessionUpdated = (() => {
|
||||
if (!activeSession?.modifiedAt) return ''
|
||||
const diff = Date.now() - new Date(activeSession.modifiedAt).getTime()
|
||||
if (diff < 60000) return t('session.timeJustNow')
|
||||
if (diff < 3600000) return t('session.timeMinutes', { n: Math.floor(diff / 60000) })
|
||||
if (diff < 86400000) return t('session.timeHours', { n: Math.floor(diff / 3600000) })
|
||||
return t('session.timeDays', { n: Math.floor(diff / 86400000) })
|
||||
})()
|
||||
const sidebarHiddenProps: HTMLAttributes<HTMLDivElement> & { inert?: '' } =
|
||||
isMobileShell && !effectiveSidebarOpen
|
||||
? { 'aria-hidden': true, inert: '' }
|
||||
@ -130,7 +145,6 @@ export function AppShell() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !isMobileShell) return
|
||||
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId)
|
||||
if (isChatTab(activeTab) || (!activeTab && !activeTabId)) return
|
||||
const nextChatTab = tabs.find(isChatTab)
|
||||
if (nextChatTab) {
|
||||
@ -138,7 +152,7 @@ export function AppShell() {
|
||||
return
|
||||
}
|
||||
useTabStore.setState({ activeTabId: null })
|
||||
}, [activeTabId, isMobileShell, ready, setActiveTab, tabs])
|
||||
}, [activeTab, activeTabId, isMobileShell, ready, setActiveTab, tabs])
|
||||
|
||||
const setEffectiveSidebarOpen = (open: boolean) => {
|
||||
if (isMobileShell) {
|
||||
@ -208,7 +222,10 @@ export function AppShell() {
|
||||
className={`min-w-0 flex-1 flex flex-col overflow-hidden${isMobileShell ? ' app-shell-main--mobile' : ''}`}
|
||||
>
|
||||
{isMobileShell ? (
|
||||
<div className="flex shrink-0 items-center justify-start border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2">
|
||||
<div
|
||||
data-testid="mobile-session-header"
|
||||
className="flex shrink-0 items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mobile-sidebar-toggle"
|
||||
@ -216,12 +233,39 @@ export function AppShell() {
|
||||
aria-expanded={effectiveSidebarOpen}
|
||||
aria-label={effectiveSidebarOpen ? t('sidebar.collapse') : t('sidebar.expand')}
|
||||
onClick={toggleEffectiveSidebar}
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">
|
||||
{effectiveSidebarOpen ? 'close' : 'menu'}
|
||||
</span>
|
||||
</button>
|
||||
{isActiveChatTab ? (
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="truncate text-[15px] font-bold leading-tight text-[var(--color-text-primary)]">
|
||||
{mobileSessionTitle}
|
||||
</h1>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap text-[10px] font-medium text-[var(--color-text-tertiary)]">
|
||||
{activeTab?.status === 'running' ? (
|
||||
<span className="flex shrink-0 items-center gap-1 text-[var(--color-text-secondary)]">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
|
||||
{t('session.active')}
|
||||
</span>
|
||||
) : null}
|
||||
{activeSession?.messageCount !== undefined && activeSession.messageCount > 0 ? (
|
||||
<>
|
||||
{activeTab?.status === 'running' ? <span aria-hidden="true">·</span> : null}
|
||||
<span>{t('session.messages', { count: activeSession.messageCount })}</span>
|
||||
</>
|
||||
) : null}
|
||||
{mobileSessionUpdated ? (
|
||||
<>
|
||||
{(activeTab?.status === 'running') || ((activeSession?.messageCount ?? 0) > 0) ? <span aria-hidden="true">·</span> : null}
|
||||
<span className="truncate">{t('session.lastUpdated', { time: mobileSessionUpdated })}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!isMobileShell ? <TabBar /> : null}
|
||||
|
||||
@ -509,6 +509,7 @@ describe('ActiveSession task polling', () => {
|
||||
expect(screen.getByTestId('active-session-chat-column')).toHaveClass('min-w-0')
|
||||
expect(screen.getByTestId('message-list')).toHaveAttribute('data-compact', 'false')
|
||||
expect(screen.getByTestId('chat-input')).toHaveAttribute('data-compact', 'false')
|
||||
expect(screen.queryByRole('heading', { name: 'Mobile Session' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('workspace-resize-handle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('session-terminal-panel')).not.toBeInTheDocument()
|
||||
|
||||
@ -358,12 +358,12 @@ export function ActiveSession() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!isMemberSession && (
|
||||
{!isMemberSession && !isMobileLayout && (
|
||||
<div
|
||||
className={
|
||||
showWorkspacePanel
|
||||
? 'flex w-full items-center border-b border-[var(--color-border)]/70 px-4 py-3'
|
||||
: `mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 ${isMobileLayout ? 'px-4 py-3' : 'px-8 py-3'}`
|
||||
: 'mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 px-8 py-3'
|
||||
}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user