From a00f1882abb33e5fcc764489c262f5a328b77cf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 10 May 2026 12:11:04 +0800 Subject: [PATCH] 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 --- .../src/components/chat/ChatInput.test.tsx | 4 ++ desktop/src/components/chat/ChatInput.tsx | 10 ++-- .../src/components/layout/AppShell.test.tsx | 35 +++++++++++++ desktop/src/components/layout/AppShell.tsx | 52 +++++++++++++++++-- desktop/src/pages/ActiveSession.test.tsx | 1 + desktop/src/pages/ActiveSession.tsx | 4 +- 6 files changed, 96 insertions(+), 10 deletions(-) diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index e719299a..6ffa12cd 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -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 }, diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index b3f21f83..39f6fd58 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -721,12 +721,13 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro return (
event.preventDefault()} onDrop={handleDrop} > diff --git a/desktop/src/components/layout/AppShell.test.tsx b/desktop/src/components/layout/AppShell.test.tsx index ba252a8d..223cb14c 100644 --- a/desktop/src/components/layout/AppShell.test.tsx +++ b/desktop/src/components/layout/AppShell.test.tsx @@ -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() + + 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__' diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 94007261..c9e578ab 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -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 & { 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 ? ( -
+
+ {isActiveChatTab ? ( +
+

+ {mobileSessionTitle} +

+
+ {activeTab?.status === 'running' ? ( + + + {t('session.active')} + + ) : null} + {activeSession?.messageCount !== undefined && activeSession.messageCount > 0 ? ( + <> + {activeTab?.status === 'running' ? : null} + {t('session.messages', { count: activeSession.messageCount })} + + ) : null} + {mobileSessionUpdated ? ( + <> + {(activeTab?.status === 'running') || ((activeSession?.messageCount ?? 0) > 0) ? : null} + {t('session.lastUpdated', { time: mobileSessionUpdated })} + + ) : null} +
+
+ ) : null}
) : null} {!isMobileShell ? : null} diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index 24f92d1e..5eeda9a2 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -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() diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index baf2537b..60198917 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -358,12 +358,12 @@ export function ActiveSession() {
) : ( <> - {!isMemberSession && ( + {!isMemberSession && !isMobileLayout && (