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:
程序员阿江(Relakkes) 2026-05-10 12:11:04 +08:00
parent 99c964e0ca
commit a00f1882ab
6 changed files with 96 additions and 10 deletions

View File

@ -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 },

View File

@ -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}
>

View File

@ -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__'

View File

@ -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}

View File

@ -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()

View File

@ -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">