mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
b4242d894d
commit
cc9bcb2017
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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 */}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: [] })
|
||||
|
||||
|
||||
@ -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)]'
|
||||
|
||||
120
desktop/src/hooks/useMobileViewport.test.tsx
Normal file
120
desktop/src/hooks/useMobileViewport.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
38
desktop/src/hooks/useMobileViewport.ts
Normal file
38
desktop/src/hooks/useMobileViewport.ts
Normal 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
|
||||
}
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user