Make H5 chat usable on phone-sized browsers

The browser H5 surface now switches to a phone-oriented shell: the sidebar becomes a closed-by-default drawer, chat stays primary, workspace and terminal panels stay off the mobile chat surface, composer controls use larger touch targets, and mobile menus avoid desktop-only widths and keyboard hints. Desktop and Tauri sidebar behavior remain on the existing store-driven path.

Constraint: H5 is personal/team browser access layered on the existing desktop web UI, so the normal desktop app must keep its current layout behavior.

Rejected: Share the global sidebarOpen default for mobile first paint | it can flash the drawer open before effects run on a phone.

Confidence: high

Scope-risk: moderate

Directive: Keep mobile-only layout branching behind useMobileViewport() && !isTauriRuntime() unless a future task explicitly redesigns the native desktop shell.

Tested: cd desktop && bunx vitest run src/hooks/useMobileViewport.test.tsx src/components/layout/AppShell.test.tsx src/components/layout/Sidebar.test.tsx src/pages/ActiveSession.test.tsx src/components/chat/ChatInput.test.tsx src/components/controls/PermissionModeSelector.test.tsx

Tested: cd desktop && bun run lint

Tested: cd desktop && bun run build
This commit is contained in:
程序员阿江(Relakkes) 2026-05-10 01:11:24 +08:00
parent b4242d894d
commit cc9bcb2017
14 changed files with 754 additions and 123 deletions

View File

@ -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: () => <button type="button">Permissions</button>,
}))
@ -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(<ChatInput />)
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')
})
})

View File

@ -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<Attachment[]>([])
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
<div
className={
isHeroComposer
? 'bg-[var(--color-surface)] px-8 pb-4'
? `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)] px-3 py-3'
: 'bg-[var(--color-surface)] px-4 py-4'
: `bg-[var(--color-surface)] ${isMobileComposer ? 'px-3 py-3' : 'px-4 py-4'}`
}
>
<div
@ -747,6 +752,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
ref={fileSearchRef}
cwd={activeLaunchWorkDir || resolvedWorkDir || ''}
filter={atFilter}
compact={isMobileComposer}
onNavigate={(relativePath) => {
if (atCursorPos < 0) return
const replacement = `@${relativePath}`
@ -829,14 +835,16 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
</button>
))}
</div>
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-4 py-2 text-xs text-[var(--color-text-tertiary)]">
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Up/Down</kbd>
<span>{t('chat.navigate')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Enter</kbd>
<span>{t('chat.select')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Esc</kbd>
<span>{t('chat.dismiss')}</span>
</div>
{!isMobileComposer ? (
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-4 py-2 text-xs text-[var(--color-text-tertiary)]">
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Up/Down</kbd>
<span>{t('chat.navigate')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Enter</kbd>
<span>{t('chat.select')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Esc</kbd>
<span>{t('chat.dismiss')}</span>
</div>
) : null}
</div>
)}
@ -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
<div className={isHeroComposer
? 'flex items-center justify-between border-t border-[var(--color-border-separator)] pt-3'
: `absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[var(--color-border-separator)] ${
compact ? 'gap-2 px-2.5 py-2' : 'px-3 py-3'
useCompactControls ? 'gap-2 px-2.5 py-2' : 'px-3 py-3'
}`}>
<div className="flex min-w-0 items-center gap-2">
{!isMemberSession && (
@ -896,13 +904,13 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
<button
onClick={() => setPlusMenuOpen((value) => !value)}
aria-label="Open composer tools"
className="rounded-[var(--radius-md)] p-1.5 text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)]"
className={`text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] ${isMobileComposer ? 'inline-flex h-11 w-11 items-center justify-center rounded-xl' : 'rounded-[var(--radius-md)] p-1.5'}`}
>
<span className="material-symbols-outlined text-[18px]">add</span>
</button>
{plusMenuOpen && (
<div className="absolute bottom-full left-0 z-50 mb-2 w-[240px] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]">
<div className={`absolute bottom-full left-0 z-50 mb-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)] ${isMobileComposer ? 'w-[min(240px,calc(100vw-32px))]' : 'w-[240px]'}`}>
<button
onClick={() => {
fileInputRef.current?.click()
@ -924,7 +932,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
)}
</div>
<PermissionModeSelector compact={compact} />
<PermissionModeSelector compact={useCompactControls} />
</>
)}
</div>
@ -937,26 +945,27 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
messageCount={messageCount}
runtimeSelectionKey={runtimeSelectionKey}
fallbackModelLabel={runtimeModelLabel}
compact={compact}
compact={useCompactControls}
/>
)}
{!isMemberSession && activeTabId && (
<ModelSelector runtimeKey={activeTabId} disabled={isActive} compact={compact} />
<ModelSelector runtimeKey={activeTabId} disabled={isActive} compact={useCompactControls} />
)}
<button
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
disabled={!isMemberSession && isActive ? false : !canSubmit}
aria-label={!isMemberSession && isActive ? t('common.stop') : isMemberSession ? t('common.send') : t('common.run')}
title={
!isMemberSession && isActive
? t('chat.stopTitle')
: compact
: iconOnlyAction
? isMemberSession
? t('common.send')
: t('common.run')
: undefined
}
className={`flex items-center justify-center gap-1 rounded-lg text-xs font-semibold transition-all hover:brightness-105 disabled:opacity-30 ${
compact ? 'h-8 w-8 px-0 py-0' : 'w-[112px] px-3 py-1.5'
iconOnlyAction ? `${isMobileComposer ? 'h-11 w-11 rounded-xl px-0 py-0' : 'h-8 w-8 px-0 py-0'}` : 'w-[112px] px-3 py-1.5'
} ${
!isMemberSession && isActive
? 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
@ -966,7 +975,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
<span className="material-symbols-outlined text-[14px]">
{!isMemberSession && isActive ? 'stop' : 'arrow_forward'}
</span>
{!compact && (!isMemberSession && isActive ? t('common.stop') : isMemberSession ? t('common.send') : t('common.run'))}
{!iconOnlyAction && (!isMemberSession && isActive ? t('common.stop') : isMemberSession ? t('common.send') : t('common.run'))}
</button>
</div>
</div>
@ -975,7 +984,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
{!isMemberSession && (
<div className={compact ? 'mt-2 flex min-w-0 justify-center px-1' : 'mt-3 px-1'}>
<div className={useCompactControls ? 'mt-2 flex min-w-0 justify-center px-1' : 'mt-3 px-1'}>
{messageCount > 0 ? (
<ProjectContextChip
workDir={resolvedWorkDir}
@ -985,7 +994,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
isWorktree={!!gitInfo?.worktree?.enabled}
worktreeSlug={gitInfo?.worktree?.slug || null}
worktreePath={gitInfo?.worktree?.path || gitInfo?.worktree?.plannedPath || null}
compact={compact}
compact={useCompactControls}
/>
) : (
<RepositoryLaunchControls

View File

@ -17,6 +17,7 @@ export type FileSearchMenuHandle = {
type Props = {
cwd: string
filter?: string
compact?: boolean
onSelect: (path: string, relativePath: string) => 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<FileSearchMenuHandle, Props>(({ cwd, filter = '', onSelect, onNavigate }, ref) => {
export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, filter = '', compact = false, onSelect, onNavigate }, ref) => {
const t = useTranslation()
const [entries, setEntries] = useState<DirEntry[]>([])
const [errorMessage, setErrorMessage] = useState<string | null>(null)
@ -158,7 +159,9 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
return (
<div
id="file-search-menu"
className="absolute left-0 bottom-full mb-2 z-50 w-full min-w-[480px] overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
className={`absolute bottom-full mb-2 z-50 w-full overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)] ${
compact ? 'left-0 right-0 min-w-0 max-w-[calc(100vw-32px)]' : 'left-0 min-w-[480px]'
}`}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Header with path */}
@ -232,14 +235,16 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
</div>
{/* Footer hint */}
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-3 py-1.5 text-[10px] text-[var(--color-text-tertiary)]">
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono"></kbd>
<span>{t('fileSearch.navigate')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Enter</kbd>
<span>{t('fileSearch.attach')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Esc</kbd>
<span>{t('fileSearch.close')}</span>
</div>
{!compact ? (
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-3 py-1.5 text-[10px] text-[var(--color-text-tertiary)]">
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono"></kbd>
<span>{t('fileSearch.navigate')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Enter</kbd>
<span>{t('fileSearch.attach')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Esc</kbd>
<span>{t('fileSearch.close')}</span>
</div>
) : null}
</div>
)
})

View File

@ -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(<PermissionModeSelector compact workDir="/repo" />)
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()
})
})

View File

@ -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<PermissionMode, string> = {
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<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(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 = (
<>
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('permMode.executionPermissions')}
</div>
{PERMISSION_ITEMS.map((item) => (
<button
key={item.value}
role="menuitem"
onClick={() => {
if (item.value === 'bypassPermissions') {
setOpen(false)
setConfirmDialog(true)
return
}
if (isControlled) {
onChange?.(item.value)
} else {
void setPermissionMode(item.value)
if (activeTabId) setSessionPermissionMode(activeTabId, item.value)
}
setOpen(false)
}}
className={`
w-full flex items-start gap-3 px-4 py-3 text-left transition-colors
hover:bg-[var(--color-surface-hover)]
${item.value === currentMode ? 'bg-[var(--color-surface-selected)]' : ''}
`}
>
<span className={`material-symbols-outlined text-[20px] mt-0.5 ${item.color || 'text-[var(--color-text-secondary)]'}`}>
{item.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{item.label}</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{item.description}</div>
</div>
{item.value === currentMode && (
<span className="material-symbols-outlined text-[16px] text-[var(--color-brand)] mt-0.5" style={{ fontVariationSettings: "'FILL' 1" }}>
check_circle
</span>
)}
</button>
))}
</>
)
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
aria-label={MODE_LABELS[currentMode]}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
title={compact ? MODE_LABELS[currentMode] : undefined}
className={`flex items-center bg-[var(--color-surface-container-low)] font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] ${
compact
? 'h-8 w-8 justify-center rounded-full p-0'
: 'gap-1.5 rounded-full px-2.5 py-1.5 text-xs'
compactButtonClass
}`}
>
<span className="material-symbols-outlined text-[14px]">{MODE_ICONS[currentMode]}</span>
@ -122,55 +187,31 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
</button>
{open && (
<div className="absolute left-0 bottom-full mb-2 w-[320px] rounded-xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] z-50 py-2">
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('permMode.executionPermissions')}
</div>
{PERMISSION_ITEMS.map((item) => (
<button
key={item.value}
onClick={() => {
if (item.value === 'bypassPermissions') {
setOpen(false)
setConfirmDialog(true)
return
}
if (isControlled) {
onChange?.(item.value)
} else {
void setPermissionMode(item.value)
if (activeTabId) setSessionPermissionMode(activeTabId, item.value)
}
setOpen(false)
}}
className={`
w-full flex items-start gap-3 px-4 py-3 text-left transition-colors
hover:bg-[var(--color-surface-hover)]
${item.value === currentMode ? 'bg-[var(--color-surface-selected)]' : ''}
`}
isMobile ? createPortal(
<div className="fixed inset-0 z-50 bg-black/20" onClick={() => setOpen(false)}>
<div
id={menuId}
ref={menuRef}
role="menu"
className="absolute inset-x-4 bottom-4 max-h-[min(70vh,520px)] overflow-y-auto rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]"
onClick={(event) => event.stopPropagation()}
>
<span className={`material-symbols-outlined text-[20px] mt-0.5 ${item.color || 'text-[var(--color-text-secondary)]'}`}>
{item.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{item.label}</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{item.description}</div>
</div>
{item.value === currentMode && (
<span className="material-symbols-outlined text-[16px] text-[var(--color-brand)] mt-0.5" style={{ fontVariationSettings: "'FILL' 1" }}>
check_circle
</span>
)}
</button>
))}
</div>
{menuContent}
</div>
</div>,
document.body,
) : (
<div id={menuId} ref={menuRef} role="menu" className="absolute left-0 bottom-full mb-2 w-[320px] rounded-xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] z-50 py-2">
{menuContent}
</div>
)
)}
{/* Bypass confirmation dialog */}
{confirmDialog && createPortal(
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 pl-[var(--sidebar-width)]" onClick={() => setConfirmDialog(false)}>
<div className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/40 ${isMobile ? 'px-4' : 'pl-[var(--sidebar-width)] pr-4'}`} onClick={() => setConfirmDialog(false)}>
<div
className="w-[420px] rounded-2xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] overflow-hidden"
className={`${isMobile ? 'w-full max-w-md' : 'w-[420px]'} rounded-2xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] overflow-hidden`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}

View File

@ -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(<AppShell />)
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')
})
})

View File

@ -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<string | null>(null)
const [h5StartupError, setH5StartupError] = useState<H5ConnectionRequiredError | null>(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<HTMLDivElement> & { 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 (
<H5ConnectionView
@ -114,26 +153,57 @@ export function AppShell() {
if (!ready) {
return (
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] text-[var(--color-text-secondary)]">
<div className="app-shell-viewport flex items-center justify-center bg-[var(--color-surface)] text-[var(--color-text-secondary)]">
{t('app.launching')}
</div>
)
}
return (
<div className="h-screen flex overflow-hidden bg-[var(--color-surface)]">
<div className={`app-shell app-shell-viewport flex overflow-hidden bg-[var(--color-surface)]${isMobileShell ? ' app-shell--mobile' : ''}`}>
{isMobileShell && effectiveSidebarOpen ? (
<button
type="button"
data-testid="sidebar-backdrop"
className="app-shell-backdrop fixed inset-0 z-40 border-0 p-0"
aria-label={t('sidebar.collapse')}
onClick={() => setEffectiveSidebarOpen(false)}
/>
) : null}
<div
id="sidebar-shell"
data-testid="sidebar-shell"
data-state={sidebarOpen ? 'open' : 'closed'}
className="sidebar-shell"
data-state={effectiveSidebarOpen ? 'open' : 'closed'}
data-mobile={isMobileShell ? 'true' : 'false'}
className={`sidebar-shell${isMobileShell ? ' sidebar-shell--mobile' : ''}`}
{...sidebarHiddenProps}
>
<Sidebar />
{!isMobileShell || effectiveSidebarOpen ? (
<Sidebar isMobile={isMobileShell} onRequestClose={() => setEffectiveSidebarOpen(false)} />
) : null}
</div>
<main
id="content-area"
data-sidebar-state={sidebarOpen ? 'open' : 'closed'}
className="min-w-0 flex-1 flex flex-col overflow-hidden"
data-sidebar-state={effectiveSidebarOpen ? 'open' : 'closed'}
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">
<button
type="button"
data-testid="mobile-sidebar-toggle"
aria-controls="sidebar-shell"
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)]"
>
<span className="material-symbols-outlined text-[20px]">
{effectiveSidebarOpen ? 'close' : 'menu'}
</span>
</button>
</div>
) : null}
<TabBar />
<ContentRouter />
</main>

View File

@ -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(<Sidebar isMobile onRequestClose={onRequestClose} />)
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: [] })

View File

@ -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<TimeGroup, string> = {
today: t('sidebar.timeGroup.today'),
@ -129,51 +138,64 @@ export function Sidebar() {
<aside
onMouseDown={handleSidebarDrag}
className="sidebar-panel relative h-full flex flex-col bg-[var(--color-surface-sidebar)] border-r border-[var(--color-border)] select-none"
data-state={sidebarOpen ? 'open' : 'closed'}
data-state={expanded ? 'open' : 'closed'}
aria-label="Sidebar"
>
<div className={`px-3 pb-2 ${isTauri && !isWindows ? 'pt-[44px]' : 'pt-3'}`}>
<div className={`flex ${sidebarOpen ? 'items-center justify-between gap-3' : 'flex-col items-center gap-2'}`}>
<div className={`flex min-w-0 items-center ${sidebarOpen ? 'gap-2.5' : 'justify-center'}`}>
<div className={`flex ${expanded ? 'items-center justify-between gap-3' : 'flex-col items-center gap-2'}`}>
<div className={`flex min-w-0 items-center ${expanded ? 'gap-2.5' : 'justify-center'}`}>
<img src="/app-icon.png" alt="" className="h-8 w-8 flex-shrink-0" />
<span
className={`sidebar-copy ${sidebarOpen ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} text-[13px] font-semibold tracking-tight text-[var(--color-text-primary)]`}
className={`sidebar-copy ${expanded ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} text-[13px] font-semibold tracking-tight text-[var(--color-text-primary)]`}
style={{ fontFamily: 'var(--font-headline)' }}
>
Claude Code <span className="text-[var(--color-primary-container)]">Haha</span>
</span>
</div>
<div className={`flex items-center ${sidebarOpen ? 'gap-1.5' : 'flex-col gap-2'}`}>
<div className={`flex items-center ${expanded ? 'gap-1.5' : 'flex-col gap-2'}`}>
<a
href="https://github.com/NanmiCoder/cc-haha"
target="_blank"
rel="noopener noreferrer"
className={`sidebar-copy ${sidebarOpen ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} inline-flex items-center justify-center rounded-md p-1 text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]`}
className={`sidebar-copy ${expanded ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} inline-flex items-center justify-center rounded-md p-1 text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]`}
title="GitHub"
tabIndex={sidebarOpen ? undefined : -1}
aria-hidden={!sidebarOpen}
tabIndex={expanded ? undefined : -1}
aria-hidden={!expanded}
>
<GitHubIcon />
</a>
<button
type="button"
onClick={toggleSidebar}
data-testid={sidebarOpen ? 'sidebar-collapse-button' : 'sidebar-expand-button'}
className={`sidebar-toggle-button ${sidebarOpen ? 'sidebar-toggle-button--open h-8 w-8' : 'sidebar-toggle-button--collapsed h-8 w-8'} flex items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)]`}
aria-label={sidebarOpen ? t('sidebar.collapse') : t('sidebar.expand')}
title={sidebarOpen ? t('sidebar.collapse') : t('sidebar.expand')}
>
<SidebarToggleIcon collapsed={!sidebarOpen} />
</button>
{isMobile ? (
<button
type="button"
onClick={closeMobileDrawer}
className="sidebar-toggle-button flex h-11 w-11 items-center justify-center rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)]"
aria-label={t('sidebar.collapse')}
title={t('sidebar.collapse')}
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
) : (
<button
type="button"
onClick={toggleSidebar}
data-testid={expanded ? 'sidebar-collapse-button' : 'sidebar-expand-button'}
className={`sidebar-toggle-button ${expanded ? 'sidebar-toggle-button--open h-8 w-8' : 'sidebar-toggle-button--collapsed h-8 w-8'} flex items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)]`}
aria-label={expanded ? t('sidebar.collapse') : t('sidebar.expand')}
title={expanded ? t('sidebar.collapse') : t('sidebar.expand')}
>
<SidebarToggleIcon collapsed={!expanded} />
</button>
)}
</div>
</div>
</div>
<div className={`px-3 pb-3 flex flex-col ${sidebarOpen ? 'gap-0.5' : 'items-center gap-2'}`}>
<div className={`px-3 pb-3 flex flex-col ${expanded ? 'gap-0.5' : 'items-center gap-2'}`}>
<NavItem
active={false}
collapsed={!sidebarOpen}
collapsed={!expanded}
label={t('sidebar.newSession')}
touchFriendly={isMobile}
onClick={async () => {
try {
const currentTabId = useTabStore.getState().activeTabId
@ -184,6 +206,7 @@ export function Sidebar() {
const sessionId = await useSessionStore.getState().createSession(workDir)
useTabStore.getState().openTab(sessionId, t('sidebar.newSession'))
useChatStore.getState().connectToSession(sessionId)
closeMobileDrawer()
} catch (error) {
addToast({
type: 'error',
@ -197,16 +220,20 @@ export function Sidebar() {
</NavItem>
<NavItem
active={activeTabId === SCHEDULED_TAB_ID}
collapsed={!sidebarOpen}
collapsed={!expanded}
label={t('sidebar.scheduled')}
onClick={() => useTabStore.getState().openTab(SCHEDULED_TAB_ID, t('sidebar.scheduled'), 'scheduled')}
touchFriendly={isMobile}
onClick={() => {
useTabStore.getState().openTab(SCHEDULED_TAB_ID, t('sidebar.scheduled'), 'scheduled')
closeMobileDrawer()
}}
icon={<ClockIcon />}
>
{t('sidebar.scheduled')}
</NavItem>
</div>
{sidebarOpen ? (
{expanded ? (
<>
<div
data-testid="sidebar-project-filter-section"
@ -286,10 +313,11 @@ export function Sidebar() {
onClick={() => {
useTabStore.getState().openTab(session.id, session.title)
useChatStore.getState().connectToSession(session.id)
closeMobileDrawer()
}}
onContextMenu={(e) => handleContextMenu(e, session.id)}
className={`
group w-full rounded-[12px] px-3 py-2 text-left text-sm transition-colors duration-200
group w-full rounded-[12px] px-3 ${isMobile ? 'py-3' : 'py-2'} text-left text-sm transition-colors duration-200
${session.id === activeTabId
? 'bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)]'
@ -331,12 +359,16 @@ export function Sidebar() {
<div className="flex-1" aria-hidden="true" />
)}
<div className={`border-t border-[var(--color-border)] p-3 ${sidebarOpen ? '' : 'flex justify-center'}`}>
<div className={`border-t border-[var(--color-border)] p-3 ${expanded ? '' : 'flex justify-center'}`}>
<NavItem
active={activeTabId === SETTINGS_TAB_ID}
collapsed={!sidebarOpen}
collapsed={!expanded}
label={t('sidebar.settings')}
onClick={() => useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')}
touchFriendly={isMobile}
onClick={() => {
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
closeMobileDrawer()
}}
icon={<span className="material-symbols-outlined text-[18px]">settings</span>}
>
{t('sidebar.settings')}
@ -408,6 +440,7 @@ function NavItem({
active,
collapsed,
label,
touchFriendly,
onClick,
icon,
children,
@ -415,6 +448,7 @@ function NavItem({
active: boolean
collapsed: boolean
label: string
touchFriendly?: boolean
onClick: () => void
icon: React.ReactNode
children: React.ReactNode
@ -426,7 +460,7 @@ function NavItem({
title={collapsed ? label : undefined}
className={`
flex items-center transition-colors duration-200
${collapsed ? 'h-10 w-10 justify-center rounded-[var(--radius-md)] px-0 py-0' : 'w-full gap-2.5 rounded-[12px] px-3 py-2.5 text-sm'}
${collapsed ? 'h-10 w-10 justify-center rounded-[var(--radius-md)] px-0 py-0' : `w-full gap-2.5 rounded-[12px] px-3 ${touchFriendly ? 'py-3' : 'py-2.5'} text-sm`}
${active
? 'bg-[var(--color-sidebar-item-active)] font-medium text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]'

View File

@ -0,0 +1,120 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { useMobileViewport } from './useMobileViewport'
function Probe() {
const isMobile = useMobileViewport()
return <div data-testid="viewport-state">{isMobile ? 'mobile' : 'desktop'}</div>
}
function createMatchMediaController(initialMatches = false, legacy = false) {
let matches = initialMatches
const listeners = new Set<(event: MediaQueryListEvent) => void>()
const addListener = vi.fn((listener: (event: MediaQueryListEvent) => void) => {
listeners.add(listener)
})
const removeListener = vi.fn((listener: (event: MediaQueryListEvent) => void) => {
listeners.delete(listener)
})
const matchMedia = vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: legacy ? undefined : vi.fn((type: string, listener: (event: MediaQueryListEvent) => void) => {
if (type === 'change') listeners.add(listener)
}),
removeEventListener: legacy ? undefined : vi.fn((type: string, listener: (event: MediaQueryListEvent) => void) => {
if (type === 'change') listeners.delete(listener)
}),
addListener,
removeListener,
dispatchEvent: vi.fn(),
}))
return {
matchMedia,
addListener,
removeListener,
emit(nextMatches: boolean) {
matches = nextMatches
const event = { matches: nextMatches, media: '(max-width: 767px)' } as MediaQueryListEvent
listeners.forEach((listener) => listener(event))
},
getListenerCount() {
return listeners.size
},
}
}
describe('useMobileViewport', () => {
const originalMatchMedia = window.matchMedia
afterEach(() => {
cleanup()
if (originalMatchMedia) {
window.matchMedia = originalMatchMedia
} else {
Reflect.deleteProperty(window, 'matchMedia')
}
})
it('defaults to desktop when matchMedia is unavailable', () => {
Reflect.deleteProperty(window, 'matchMedia')
render(<Probe />)
expect(screen.getByTestId('viewport-state')).toHaveTextContent('desktop')
})
it('tracks viewport changes and removes the listener on cleanup', () => {
const controller = createMatchMediaController(false)
window.matchMedia = controller.matchMedia as typeof window.matchMedia
const { unmount } = render(<Probe />)
expect(screen.getByTestId('viewport-state')).toHaveTextContent('desktop')
expect(controller.matchMedia).toHaveBeenCalledWith('(max-width: 767px)')
expect(controller.getListenerCount()).toBe(1)
act(() => {
controller.emit(true)
})
expect(screen.getByTestId('viewport-state')).toHaveTextContent('mobile')
unmount()
expect(controller.getListenerCount()).toBe(0)
})
it('reads the initial media query before first paint', () => {
const controller = createMatchMediaController(true)
window.matchMedia = controller.matchMedia as typeof window.matchMedia
render(<Probe />)
expect(screen.getByTestId('viewport-state')).toHaveTextContent('mobile')
})
it('falls back to legacy media query listeners', () => {
const controller = createMatchMediaController(false, true)
window.matchMedia = controller.matchMedia as typeof window.matchMedia
const { unmount } = render(<Probe />)
expect(controller.addListener).toHaveBeenCalledTimes(1)
act(() => {
controller.emit(true)
})
expect(screen.getByTestId('viewport-state')).toHaveTextContent('mobile')
unmount()
expect(controller.removeListener).toHaveBeenCalledTimes(1)
expect(controller.getListenerCount()).toBe(0)
})
})

View File

@ -0,0 +1,38 @@
import { useEffect, useState } from 'react'
const MOBILE_VIEWPORT_QUERY = '(max-width: 767px)'
function getInitialMobileViewport() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
return window.matchMedia(MOBILE_VIEWPORT_QUERY).matches
}
export function useMobileViewport() {
const [isMobile, setIsMobile] = useState(getInitialMobileViewport)
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
const mediaQuery = window.matchMedia(MOBILE_VIEWPORT_QUERY)
const handleChange = (event: MediaQueryListEvent) => {
setIsMobile(event.matches)
}
setIsMobile(mediaQuery.matches)
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', handleChange)
} else {
mediaQuery.addListener(handleChange)
}
return () => {
if (typeof mediaQuery.removeEventListener === 'function') {
mediaQuery.removeEventListener('change', handleChange)
} else {
mediaQuery.removeListener(handleChange)
}
}
}, [])
return isMobile
}

View File

@ -3,6 +3,14 @@ import { cleanup, createEvent, fireEvent, render, screen, within } from '@testin
import '@testing-library/jest-dom'
import { act } from 'react'
const viewportMocks = vi.hoisted(() => ({
isMobile: false,
}))
vi.mock('../hooks/useMobileViewport', () => ({
useMobileViewport: () => viewportMocks.isMobile,
}))
vi.mock('../components/chat/MessageList', () => ({
MessageList: ({ compact }: { compact?: boolean }) => (
<div data-testid="message-list" data-compact={compact ? 'true' : 'false'} />
@ -66,6 +74,7 @@ import {
afterEach(() => {
cleanup()
vi.useRealTimers()
viewportMocks.isMobile = false
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: {} })
@ -447,6 +456,65 @@ describe('ActiveSession task polling', () => {
expect(screen.getByTestId('message-list')).toBeInTheDocument()
})
it('keeps chat as the primary surface on mobile by hiding workspace and terminal panels', () => {
const sessionId = 'mobile-session'
viewportMocks.isMobile = true
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'Mobile Session',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 1,
projectPath: '/tmp/project-root',
workDir: '/tmp/project-root',
workDirExists: true,
}],
activeSessionId: sessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'Mobile Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useWorkspacePanelStore.getState().openPanel(sessionId)
useTerminalPanelStore.getState().openPanel(sessionId)
render(<ActiveSession />)
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.queryByTestId('workspace-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('workspace-resize-handle')).not.toBeInTheDocument()
expect(screen.queryByTestId('session-terminal-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('terminal-resize-handle')).not.toBeInTheDocument()
})
it('renders a bottom terminal panel in the current session cwd and can promote it to a tab', async () => {
const sessionId = 'terminal-session'

View File

@ -26,6 +26,8 @@ import { WorkspacePanel } from '../components/workspace/WorkspacePanel'
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
import { TerminalSettings } from './TerminalSettings'
import type { SessionListItem } from '../types/session'
import { useMobileViewport } from '../hooks/useMobileViewport'
import { isTauriRuntime } from '../lib/desktopRuntime'
const TASK_POLL_INTERVAL_MS = 1000
const WORKSPACE_RESIZE_STEP = 32
@ -198,6 +200,7 @@ function TerminalResizeHandle() {
}
export function ActiveSession() {
const isMobileLayout = useMobileViewport() && !isTauriRuntime()
const activeTabId = useTabStore((s) => s.activeTabId)
const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type ?? null)
const sessions = useSessionStore((s) => s.sessions)
@ -215,12 +218,12 @@ export function ActiveSession() {
const activeTeam = useTeamStore((s) => s.activeTeam)
const isMemberSession = !!memberInfo
const showWorkspacePanel = useWorkspacePanelStore((state) =>
activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession
activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession && !isMobileLayout
? state.isPanelOpen(activeTabId)
: false,
)
const showTerminalPanel = useTerminalPanelStore((state) =>
activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession
activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession && !isMobileLayout
? state.isPanelOpen(activeTabId)
: false,
)
@ -281,7 +284,7 @@ export function ActiveSession() {
<div data-testid="active-session-content-row" className="flex min-h-0 min-w-0 flex-1">
<div
data-testid="active-session-chat-column"
className={`flex flex-col ${showWorkspacePanel ? CHAT_COLUMN_WITH_WORKSPACE_CLASS : 'min-w-[360px] flex-1'}`}
className={`flex flex-col ${showWorkspacePanel ? CHAT_COLUMN_WITH_WORKSPACE_CLASS : isMobileLayout ? 'min-w-0 flex-1' : 'min-w-[360px] flex-1'}`}
>
{isMemberSession && (
<div className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
@ -360,7 +363,7 @@ export function ActiveSession() {
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 px-8 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'}`
}
>
<div className="min-w-0 flex-1">

View File

@ -516,6 +516,23 @@ button, input, textarea, select, a, [role="button"] {
box-shadow: var(--shadow-focus-ring), var(--shadow-dropdown);
}
.app-shell-viewport {
min-height: 100vh;
min-height: 100dvh;
height: 100vh;
height: 100dvh;
}
.app-shell {
position: relative;
width: 100%;
overflow-x: hidden;
}
.app-shell-backdrop {
background: var(--color-overlay-scrim);
}
.sidebar-shell {
width: var(--sidebar-width);
flex-shrink: 0;
@ -528,6 +545,28 @@ button, input, textarea, select, a, [role="button"] {
width: var(--sidebar-rail-width);
}
.app-shell--mobile .sidebar-shell {
position: fixed;
inset: 0 auto 0 0;
z-index: 50;
width: min(88vw, var(--sidebar-width));
max-width: var(--sidebar-width);
overflow: visible;
transform: translateX(-100%);
transition:
transform var(--motion-sidebar-duration) var(--motion-sidebar-easing),
width var(--motion-sidebar-duration) var(--motion-sidebar-easing);
}
.app-shell--mobile .sidebar-shell[data-state="closed"] {
pointer-events: none;
}
.app-shell--mobile .sidebar-shell[data-state="open"] {
transform: translateX(0);
pointer-events: auto;
}
.sidebar-panel {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
@ -543,6 +582,13 @@ button, input, textarea, select, a, [role="button"] {
min-width: var(--sidebar-rail-width);
}
.app-shell--mobile .sidebar-panel,
.app-shell--mobile .sidebar-panel[data-state="closed"] {
width: min(88vw, var(--sidebar-width));
min-width: min(88vw, var(--sidebar-width));
box-shadow: var(--shadow-dropdown);
}
.sidebar-toggle-button {
color: color-mix(in srgb, var(--color-text-secondary) 72%, transparent);
background: transparent;
@ -663,6 +709,14 @@ button, input, textarea, select, a, [role="button"] {
}
}
@media (max-width: 767px) {
.app-shell-main--mobile {
width: 100%;
min-width: 0;
overflow-x: hidden;
}
}
.markdown-prose .md-table-wrap table {
border-collapse: separate;
border-spacing: 0;