{
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
+
{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 (
{/* 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) => (
-
,
+ 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 ? (
+