diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index cdba591d..e719299a 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -2,6 +2,10 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import '@testing-library/jest-dom' +const viewportMocks = vi.hoisted(() => ({ + isMobile: false, +})) + const mocks = vi.hoisted(() => ({ create: vi.fn(), delete: vi.fn(), @@ -46,6 +50,10 @@ vi.mock('../../api/websocket', () => ({ }, })) +vi.mock('../../hooks/useMobileViewport', () => ({ + useMobileViewport: () => viewportMocks.isMobile, +})) + vi.mock('../controls/PermissionModeSelector', () => ({ PermissionModeSelector: () => , })) @@ -104,6 +112,7 @@ describe('ChatInput file mentions', () => { beforeEach(() => { vi.clearAllMocks() + viewportMocks.isMobile = false useSettingsStore.setState({ locale: 'en' }) useChatStore.setState(initialChatState, true) useSessionStore.setState(initialSessionState, true) @@ -428,4 +437,40 @@ describe('ChatInput file mentions', () => { attachments: [{ name: 'conditions.py', path: '/repo/backend/src/conditions.py' }], }) }) + + it('uses larger icon-only mobile action buttons for browser H5 access', async () => { + viewportMocks.isMobile = true + mocks.search.mockResolvedValueOnce({ + currentPath: '/repo', + parentPath: null, + query: 'cond', + entries: [ + { name: 'conditions.py', path: '/repo/conditions.py', isDirectory: false }, + ], + }) + + render() + + await waitFor(() => { + expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId) + }) + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'ship it', selectionStart: 7 }, + }) + + 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() + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: '@cond', selectionStart: 5 }, + }) + + expect(await screen.findByText('conditions.py')).toBeInTheDocument() + const fileSearchMenu = document.getElementById('file-search-menu') + expect(fileSearchMenu).toHaveClass('min-w-0') + expect(fileSearchMenu).not.toHaveClass('min-w-[480px]') + expect(fileSearchMenu).not.toHaveTextContent('Navigate') + }) }) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 087efe07..08d363eb 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -29,6 +29,8 @@ import { replaceSlashToken, resolveSlashUiAction, } from './composerUtils' +import { useMobileViewport } from '../../hooks/useMobileViewport' +import { isTauriRuntime } from '../../lib/desktopRuntime' type GitInfo = SessionGitInfo @@ -68,6 +70,7 @@ function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Atta export function ChatInput({ variant = 'default', compact = false }: ChatInputProps) { const t = useTranslation() + const isMobileComposer = useMobileViewport() && !isTauriRuntime() const [input, setInput] = useState('') const [attachments, setAttachments] = useState([]) const [plusMenuOpen, setPlusMenuOpen] = useState(false) @@ -123,6 +126,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const isHeroComposer = variant === 'hero' && !isMemberSession && !compact const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined const showLaunchControls = !isMemberSession && messageCount === 0 + const useCompactControls = compact || isMobileComposer + const iconOnlyAction = compact || isMobileComposer const activeLaunchWorkDir = showLaunchControls ? (launchWorkDir || resolvedWorkDir || '') : (resolvedWorkDir || '') const pendingSlashUiAction = !isMemberSession && input.trim().startsWith('/') ? resolveSlashUiAction(input.trim().slice(1)) @@ -718,10 +723,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
{ if (atCursorPos < 0) return const replacement = `@${relativePath}` @@ -829,14 +835,16 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro ))}
-
- Up/Down - {t('chat.navigate')} - Enter - {t('chat.select')} - Esc - {t('chat.dismiss')} -
+ {!isMobileComposer ? ( +
+ Up/Down + {t('chat.navigate')} + Enter + {t('chat.select')} + Esc + {t('chat.dismiss')} +
+ ) : null}
)} @@ -879,7 +887,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro disabled={isWorkspaceMissing} rows={1} className={`w-full resize-none bg-transparent text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50 ${ - compact ? 'py-1.5 pb-11' : 'py-2 pb-12' + useCompactControls ? 'py-1.5 pb-14' : 'py-2 pb-12' }`} /> )} @@ -887,7 +895,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
{!isMemberSession && ( @@ -896,13 +904,13 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro {plusMenuOpen && ( -
+
- + )}
@@ -937,26 +945,27 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro messageCount={messageCount} runtimeSelectionKey={runtimeSelectionKey} fallbackModelLabel={runtimeModelLabel} - compact={compact} + compact={useCompactControls} /> )} {!isMemberSession && activeTabId && ( - + )}
@@ -975,7 +984,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro {!isMemberSession && ( -
+
{messageCount > 0 ? ( ) : ( void onNavigate?: (relativePath: string) => void } @@ -25,7 +26,7 @@ function joinRelativePath(base: string, name: string) { return [base.replace(/\/+$/, ''), name].filter(Boolean).join('/') } -export const FileSearchMenu = forwardRef(({ cwd, filter = '', onSelect, onNavigate }, ref) => { +export const FileSearchMenu = forwardRef(({ cwd, filter = '', compact = false, onSelect, onNavigate }, ref) => { const t = useTranslation() const [entries, setEntries] = useState([]) const [errorMessage, setErrorMessage] = useState(null) @@ -158,7 +159,9 @@ export const FileSearchMenu = forwardRef(({ cwd, fi return (
e.stopPropagation()} > {/* Header with path */} @@ -232,14 +235,16 @@ export const FileSearchMenu = forwardRef(({ cwd, fi
{/* Footer hint */} -
- ↑↓ - {t('fileSearch.navigate')} - Enter - {t('fileSearch.attach')} - Esc - {t('fileSearch.close')} -
+ {!compact ? ( +
+ ↑↓ + {t('fileSearch.navigate')} + Enter + {t('fileSearch.attach')} + Esc + {t('fileSearch.close')} +
+ ) : null}
) }) diff --git a/desktop/src/components/controls/PermissionModeSelector.test.tsx b/desktop/src/components/controls/PermissionModeSelector.test.tsx new file mode 100644 index 00000000..2e17798c --- /dev/null +++ b/desktop/src/components/controls/PermissionModeSelector.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import '@testing-library/jest-dom' + +const viewportMocks = vi.hoisted(() => ({ + isMobile: false, +})) + +vi.mock('../../hooks/useMobileViewport', () => ({ + useMobileViewport: () => viewportMocks.isMobile, +})) + +vi.mock('../../lib/desktopRuntime', () => ({ + isTauriRuntime: () => false, +})) + +vi.mock('../../i18n', () => ({ + useTranslation: () => (key: string) => ({ + 'permMode.askPermissions': 'Ask permissions', + 'permMode.askPermDesc': 'Ask before changing files or running commands', + 'permMode.autoAccept': 'Auto accept edits', + 'permMode.autoAcceptDesc': 'Automatically accept edit operations', + 'permMode.planMode': 'Plan mode', + 'permMode.planModeDesc': 'Plan before executing', + 'permMode.bypass': 'Bypass permissions', + 'permMode.bypassDesc': 'Run without permission prompts', + 'permMode.executionPermissions': 'Execution Permissions', + 'permMode.label.default': 'Ask permissions', + 'permMode.label.acceptEdits': 'Auto accept edits', + 'permMode.label.plan': 'Plan mode', + 'permMode.label.bypassPermissions': 'Bypass permissions', + 'permMode.label.dontAsk': 'Bypass permissions', + 'permMode.enableBypassTitle': 'Enable bypass mode', + 'permMode.enableBypassSubtitle': 'This is risky', + 'permMode.enableBypassBody': 'Bypass permissions for this workspace.', + 'permMode.permReadWrite': 'Read and write files', + 'permMode.permShell': 'Run shell commands', + 'permMode.permPackages': 'Install packages', + 'permMode.enableBypassBtn': 'Enable bypass', + 'common.cancel': 'Cancel', + }[key] ?? key), +})) + +import { PermissionModeSelector } from './PermissionModeSelector' +import { useSettingsStore } from '../../stores/settingsStore' +import { useSessionStore } from '../../stores/sessionStore' +import { useTabStore } from '../../stores/tabStore' + +describe('PermissionModeSelector mobile access', () => { + beforeEach(() => { + viewportMocks.isMobile = false + useSettingsStore.setState({ permissionMode: 'default' }) + useSessionStore.setState({ sessions: [], activeSessionId: null }) + useTabStore.setState({ activeTabId: null, tabs: [] }) + }) + + it('labels the compact mobile trigger and opens a phone-sized menu sheet', () => { + viewportMocks.isMobile = true + + render() + + const trigger = screen.getByRole('button', { name: 'Ask permissions' }) + expect(trigger).toHaveClass('h-11', 'w-11') + expect(trigger).toHaveAttribute('aria-haspopup', 'menu') + expect(trigger).toHaveAttribute('aria-expanded', 'false') + + fireEvent.click(trigger) + + expect(trigger).toHaveAttribute('aria-expanded', 'true') + expect(trigger).toHaveAttribute('aria-controls', 'permission-mode-menu') + expect(screen.getByRole('menu')).toHaveClass('inset-x-4', 'bottom-4') + expect(screen.getByRole('menuitem', { name: /Auto accept edits/ })).toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/controls/PermissionModeSelector.tsx b/desktop/src/components/controls/PermissionModeSelector.tsx index dced8e02..a049738d 100644 --- a/desktop/src/components/controls/PermissionModeSelector.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.tsx @@ -7,6 +7,8 @@ import { useSessionStore } from '../../stores/sessionStore' import { useTabStore } from '../../stores/tabStore' import { useTranslation } from '../../i18n' import type { PermissionMode } from '../../types/settings' +import { useMobileViewport } from '../../hooks/useMobileViewport' +import { isTauriRuntime } from '../../lib/desktopRuntime' const MODE_ICONS: Record = { default: 'verified_user', @@ -27,6 +29,7 @@ type Props = { export function PermissionModeSelector({ workDir: workDirProp, compact = false, value, onChange }: Props = {}) { const t = useTranslation() + const isMobile = useMobileViewport() && !isTauriRuntime() const { permissionMode: storeMode, setPermissionMode } = useSettingsStore() const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode) const activeTabId = useTabStore((s) => s.activeTabId) @@ -35,6 +38,7 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, const [open, setOpen] = useState(false) const [confirmDialog, setConfirmDialog] = useState(false) const ref = useRef(null) + const menuRef = useRef(null) const isControlled = value !== undefined const currentMode = isControlled ? value : storeMode @@ -84,11 +88,24 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, const activeSession = sessions.find((s) => s.id === activeSessionId) const workDir = workDirProp || activeSession?.workDir || '~' + const compactButtonClass = compact + ? isMobile + ? 'h-11 w-11 justify-center rounded-xl p-0' + : 'h-8 w-8 justify-center rounded-full p-0' + : 'gap-1.5 rounded-full px-2.5 py-1.5 text-xs' + const menuId = 'permission-mode-menu' useEffect(() => { if (!open) return const handleClick = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + const target = e.target as Node + if ( + ref.current && + !ref.current.contains(target) && + !menuRef.current?.contains(target) + ) { + setOpen(false) + } } const handleEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) @@ -101,15 +118,63 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false, } }, [open]) + const menuContent = ( + <> +
+ {t('permMode.executionPermissions')} +
+ {PERMISSION_ITEMS.map((item) => ( + + ))} + + ) + return (
{open && ( -
-
- {t('permMode.executionPermissions')} -
- {PERMISSION_ITEMS.map((item) => ( - - ))} -
+ {menuContent} +
+
, + document.body, + ) : ( + + ) )} {/* Bypass confirmation dialog */} {confirmDialog && createPortal( -
setConfirmDialog(false)}> +
setConfirmDialog(false)}>
e.stopPropagation()} > {/* Header */} diff --git a/desktop/src/components/layout/AppShell.test.tsx b/desktop/src/components/layout/AppShell.test.tsx index 4dd87bab..1471781b 100644 --- a/desktop/src/components/layout/AppShell.test.tsx +++ b/desktop/src/components/layout/AppShell.test.tsx @@ -1,10 +1,12 @@ 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' const mocks = vi.hoisted(() => ({ initializeDesktopServerUrl: vi.fn(), isTauriRuntime: false, + isMobile: false, fetchAll: vi.fn(), restoreTabs: vi.fn(), connectToSession: vi.fn(), @@ -26,9 +28,8 @@ vi.mock('../../stores/settingsStore', () => ({ selector({ fetchAll: mocks.fetchAll }), })) -vi.mock('../../stores/uiStore', () => ({ - useUIStore: (selector: (state: { sidebarOpen: boolean }) => unknown) => - selector({ sidebarOpen: true }), +vi.mock('../../hooks/useMobileViewport', () => ({ + useMobileViewport: () => mocks.isMobile, })) vi.mock('../../stores/tabStore', () => ({ @@ -95,11 +96,13 @@ describe('AppShell boot flow', () => { beforeEach(() => { vi.clearAllMocks() mocks.isTauriRuntime = false + mocks.isMobile = false mocks.initializeDesktopServerUrl.mockResolvedValue('http://127.0.0.1:3456') mocks.fetchAll.mockResolvedValue(undefined) mocks.restoreTabs.mockResolvedValue(undefined) mocks.tabState.activeTabId = null mocks.tabState.tabs = [] + useUIStore.setState({ sidebarOpen: true }) }) it('renders the desktop chrome after server and settings bootstrap', async () => { @@ -218,4 +221,35 @@ describe('AppShell boot flow', () => { expect(await screen.findByText('app.serverFailed')).toBeInTheDocument() expect(screen.queryByText('h5 connection view')).not.toBeInTheDocument() }) + + it('renders a mobile drawer toggle and backdrop in browser H5 mode', async () => { + mocks.isMobile = true + + render() + + await screen.findByText('content loaded') + + await waitFor(() => { + expect(useUIStore.getState().sidebarOpen).toBe(false) + }) + + expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('data-state', 'closed') + expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('aria-hidden', 'true') + expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('inert') + expect(screen.queryByText('sidebar loaded')).not.toBeInTheDocument() + expect(screen.getByTestId('mobile-sidebar-toggle')).toBeInTheDocument() + expect(screen.queryByTestId('sidebar-backdrop')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('mobile-sidebar-toggle')) + + expect(useUIStore.getState().sidebarOpen).toBe(true) + expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('data-state', 'open') + expect(screen.getByText('sidebar loaded')).toBeInTheDocument() + expect(screen.getByTestId('sidebar-backdrop')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('sidebar-backdrop')) + + expect(useUIStore.getState().sidebarOpen).toBe(false) + expect(screen.getByTestId('sidebar-shell')).toHaveAttribute('data-state', 'closed') + }) }) diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 11147d9a..ff4838ec 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState, type HTMLAttributes } from 'react' import { Sidebar } from './Sidebar' import { ContentRouter } from './ContentRouter' import { ToastContainer } from '../shared/Toast' @@ -18,16 +18,27 @@ import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' import { useTranslation } from '../../i18n' import { H5ConnectionView } from './H5ConnectionView' +import { useMobileViewport } from '../../hooks/useMobileViewport' export function AppShell() { const fetchSettings = useSettingsStore((s) => s.fetchAll) const sidebarOpen = useUIStore((s) => s.sidebarOpen) + const toggleSidebar = useUIStore((s) => s.toggleSidebar) + const setSidebarOpen = useUIStore((s) => s.setSidebarOpen) const [ready, setReady] = useState(false) const [startupError, setStartupError] = useState(null) const [h5StartupError, setH5StartupError] = useState(null) const [bootstrapNonce, setBootstrapNonce] = useState(0) + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) const t = useTranslation() const tauriRuntime = isTauriRuntime() + const isMobileShell = useMobileViewport() && !tauriRuntime + const wasMobileShellRef = useRef(false) + const effectiveSidebarOpen = isMobileShell ? mobileSidebarOpen : sidebarOpen + const sidebarHiddenProps: HTMLAttributes & { inert?: '' } = + isMobileShell && !effectiveSidebarOpen + ? { 'aria-hidden': true, inert: '' } + : {} useEffect(() => { let cancelled = false @@ -98,6 +109,34 @@ export function AppShell() { useKeyboardShortcuts() + useEffect(() => { + if (isMobileShell && !wasMobileShellRef.current) { + setMobileSidebarOpen(false) + setSidebarOpen(false) + } + if (!isMobileShell && wasMobileShellRef.current) { + setMobileSidebarOpen(false) + } + wasMobileShellRef.current = isMobileShell + }, [isMobileShell, setSidebarOpen]) + + const setEffectiveSidebarOpen = (open: boolean) => { + if (isMobileShell) { + setMobileSidebarOpen(open) + setSidebarOpen(open) + return + } + setSidebarOpen(open) + } + + const toggleEffectiveSidebar = () => { + if (isMobileShell) { + setEffectiveSidebarOpen(!mobileSidebarOpen) + return + } + toggleSidebar() + } + if (!tauriRuntime && h5StartupError) { return ( +
{t('app.launching')}
) } return ( -
+
+ {isMobileShell && effectiveSidebarOpen ? ( + +
+ ) : null} diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index dcfdc1f0..d9786801 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -204,6 +204,42 @@ describe('Sidebar', () => { expect(screen.getByTestId('sidebar-session-list-section')).toHaveClass('flex', 'flex-1', 'min-h-0', 'flex-col') }) + it('closes the mobile drawer after navigation actions', async () => { + const onRequestClose = vi.fn() + createSession.mockResolvedValue('session-mobile-new') + useSessionStore.setState({ + sessions: [ + { + id: 'session-1', + title: 'Open Session', + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }, + ], + }) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Scheduled' })) + expect(onRequestClose).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: /Open Session/ })) + expect(onRequestClose).toHaveBeenCalledTimes(2) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'New Session' })) + }) + + await waitFor(() => { + expect(createSession).toHaveBeenCalled() + }) + expect(onRequestClose).toHaveBeenCalledTimes(3) + }) + it('shows a loading state instead of an empty session list while initial fetch is pending', () => { useSessionStore.setState({ isLoading: true, sessions: [] }) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index c20a822c..728e43d3 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -15,7 +15,12 @@ type TimeGroup = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'older' const TIME_GROUP_ORDER: TimeGroup[] = ['today', 'yesterday', 'last7days', 'last30days', 'older'] -export function Sidebar() { +type SidebarProps = { + isMobile?: boolean + onRequestClose?: () => void +} + +export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const sessions = useSessionStore((s) => s.sessions) const selectedProjects = useSessionStore((s) => s.selectedProjects) const isLoading = useSessionStore((s) => s.isLoading) @@ -116,6 +121,10 @@ export function Sidebar() { }, []) const t = useTranslation() + const expanded = isMobile ? true : sidebarOpen + const closeMobileDrawer = useCallback(() => { + if (isMobile) onRequestClose?.() + }, [isMobile, onRequestClose]) const timeGroupLabels: Record = { today: t('sidebar.timeGroup.today'), @@ -129,51 +138,64 @@ export function Sidebar() {