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 && (