From 25d616f009bed76839b2486b4f1efa9423a0cb1b Mon Sep 17 00:00:00 2001 From: Jovines <1246634075@qq.com> Date: Tue, 24 Feb 2026 17:44:43 +0800 Subject: [PATCH] feat(mobile): refactor drawer system and session status bar (#494) * feat(mobile): refactor drawer system with swipe gestures and improved status bar - Add DrawerContext for centralized drawer state management - Implement swipe gesture support for left/right drawers - Refactor MobileSessionStatusBar with token usage indicator - Update Header component with drawer toggle props - Improve RightSidebar touch handling - Add mobile-specific styling improvements - Update UI store for drawer state management * feat(mobile): update empty session hint text to clarify swipe gesture area * fix(mobile): restore MobileAgentButton tap-to-cycle and long-press behavior - Revert removal of tap-to-cycle agent switching functionality - Re-add onCycleAgent prop and long-press detection (500ms) - Click now cycles through primary agents, long-press opens selector panel - Fixes regression from commit 569cc411 * feat(mobile): add project switcher bar and fix button interactions - Add ProjectBar component to MobileSessionStatusBar for quick project switching - Add useProjectStatus hook to track project-level session indicators - Integrate project store with directory store for project management - Add project status indicators (running/unread) to session status bar - Fix MobileAgentButton pointer event handling with preventDefault - Support horizontal scroll in project bar with touch gesture isolation * feat(mobile): add long-press to remove projects and filter sessions by project * fix: improve mobile agent switching UX * fix: move drawer swipe hook to dedicated file * refactor(mobile): simplify session status bar, remove More/Less toggle button * fix: git diff navigation on mobile in sidebar mode * feat(mobile): add configurable status bar and refine mobile chat controls --------- Co-authored-by: Jovines Co-authored-by: Bohdan Triapitsyn --- packages/ui/src/components/chat/ChatInput.tsx | 6 +- .../src/components/chat/MobileAgentButton.tsx | 45 +- .../chat/MobileSessionStatusBar.tsx | 747 ++++++++++++++++-- packages/ui/src/components/chat/StatusRow.tsx | 11 +- packages/ui/src/components/layout/Header.tsx | 624 ++++++++------- .../ui/src/components/layout/MainLayout.tsx | 272 ++++++- .../ui/src/components/layout/RightSidebar.tsx | 17 +- .../sections/openchamber/OpenChamberPage.tsx | 4 +- .../openchamber/OpenChamberVisualSettings.tsx | 30 +- packages/ui/src/components/views/GitView.tsx | 13 +- .../ui/src/components/views/SettingsView.tsx | 1 + packages/ui/src/contexts/DrawerContext.tsx | 38 + packages/ui/src/hooks/useDrawerSwipe.ts | 156 ++++ packages/ui/src/stores/useUIStore.ts | 20 + packages/ui/src/styles/mobile.css | 6 + 15 files changed, 1564 insertions(+), 426 deletions(-) create mode 100644 packages/ui/src/contexts/DrawerContext.tsx create mode 100644 packages/ui/src/hooks/useDrawerSwipe.ts diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 63d6bcf6..2d5ff587 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -2249,7 +2249,11 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo
- setMobileControlsPanel('agent')} className="min-w-0 flex-shrink" /> + setMobileControlsPanel('agent')} + onCycleAgent={handleCycleAgent} + className="min-w-0 flex-shrink" + />
diff --git a/packages/ui/src/components/chat/MobileAgentButton.tsx b/packages/ui/src/components/chat/MobileAgentButton.tsx index eb157f30..7197cb48 100644 --- a/packages/ui/src/components/chat/MobileAgentButton.tsx +++ b/packages/ui/src/components/chat/MobileAgentButton.tsx @@ -6,32 +6,71 @@ import { getAgentDisplayName } from './mobileControlsUtils'; import { getAgentColor } from '@/lib/agentColors'; interface MobileAgentButtonProps { + onCycleAgent: () => void; onOpenAgentPanel: () => void; className?: string; } -export const MobileAgentButton: React.FC = ({ onOpenAgentPanel, className }) => { +const LONG_PRESS_MS = 500; + +export const MobileAgentButton: React.FC = ({ onOpenAgentPanel, onCycleAgent, className }) => { const { currentAgentName, getVisibleAgents } = useConfigStore(); const currentSessionId = useSessionStore((state) => state.currentSessionId); const sessionAgentName = useSessionStore((state) => currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null ); + const longPressTimerRef = React.useRef | null>(null); + const longPressTriggeredRef = React.useRef(false); const agents = getVisibleAgents(); const uiAgentName = currentSessionId ? (sessionAgentName || currentAgentName) : currentAgentName; const agentLabel = getAgentDisplayName(agents, uiAgentName); const agentColor = getAgentColor(uiAgentName); + const clearLongPressTimer = React.useCallback(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + }, []); + + const startLongPressTimer = React.useCallback(() => { + clearLongPressTimer(); + longPressTriggeredRef.current = false; + longPressTimerRef.current = setTimeout(() => { + longPressTriggeredRef.current = true; + onOpenAgentPanel(); + }, LONG_PRESS_MS); + }, [clearLongPressTimer, onOpenAgentPanel]); + + React.useEffect(() => { + return () => clearLongPressTimer(); + }, [clearLongPressTimer]); + return ( + ); +} + + + +// Hook for long press +function useLongPress( + onLongPress: () => void, + onClick: () => void, + ms = 500 +) { + const timerRef = React.useRef(null); + const isLongPress = React.useRef(false); + + const start = React.useCallback(() => { + isLongPress.current = false; + timerRef.current = setTimeout(() => { + isLongPress.current = true; + onLongPress(); + }, ms); + }, [onLongPress, ms]); + + const end = React.useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const handleClick = React.useCallback(() => { + if (!isLongPress.current) { + onClick(); + } + }, [onClick]); + + return { + onMouseDown: start, + onMouseUp: end, + onMouseLeave: end, + onTouchStart: start, + onTouchEnd: end, + onClick: handleClick, + }; +} + +// Project button component with long press support +interface ProjectButtonProps { + project: ProjectEntry; + isActive: boolean; + status: { hasRunning: boolean; hasUnread: boolean }; + projectColorVar: string | null; + onProjectSwitch: () => void; + onRemoveProject?: () => void; + formatProjectLabel: (project: ProjectEntry) => string; +} + +function ProjectButton({ + project, + isActive, + status, + projectColorVar, + onProjectSwitch, + onRemoveProject, + formatProjectLabel, +}: ProjectButtonProps) { + const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null; + + const longPressHandlers = useLongPress( + () => { + if (onRemoveProject) { + onRemoveProject(); + } + }, + onProjectSwitch, + 600 + ); + + return ( + ); } +// Project bar component for expanded view +interface ProjectBarProps { + projects: ProjectEntry[]; + activeProjectId: string | null; + getProjectStatus: (path: string) => { hasRunning: boolean; hasUnread: boolean }; + onProjectSwitch: (projectId: string) => void; + onAddProject: () => void; + onRemoveProject?: (projectId: string) => void; + homeDirectory: string | null; +} + +function ProjectBar({ + projects, + activeProjectId, + getProjectStatus, + onProjectSwitch, + onAddProject, + onRemoveProject, + homeDirectory +}: ProjectBarProps) { + const scrollRef = React.useRef(null); + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); + const [projectToDelete, setProjectToDelete] = React.useState(null); + + // Scroll active project into view + React.useEffect(() => { + if (scrollRef.current && activeProjectId) { + const activeElement = scrollRef.current.querySelector(`[data-project-id="${activeProjectId}"]`); + if (activeElement) { + activeElement.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); + } + } + }, [activeProjectId]); + + const handleLongPress = (project: ProjectEntry) => { + setProjectToDelete(project); + setDeleteDialogOpen(true); + }; + + const handleConfirmDelete = () => { + if (projectToDelete && onRemoveProject) { + onRemoveProject(projectToDelete.id); + } + setDeleteDialogOpen(false); + setProjectToDelete(null); + }; + + if (projects.length === 0) { + return ( +
+ No projects + +
+ ); + } + + const formatProjectLabel = (project: ProjectEntry): string => { + return project.label?.trim() + || formatDirectoryName(project.path, homeDirectory) + || project.path; + }; + + // Handle touch events to prevent drawer swipe when scrolling project bar + const handleTouchStart = (e: React.TouchEvent) => { + // Store initial touch position for this component + (e.currentTarget as HTMLElement).dataset.touchStartX = String(e.touches[0].clientX); + (e.currentTarget as HTMLElement).dataset.touchStartY = String(e.touches[0].clientY); + }; + + const handleTouchMove = (e: React.TouchEvent) => { + const target = e.currentTarget as HTMLElement; + const startX = Number(target.dataset.touchStartX || 0); + const startY = Number(target.dataset.touchStartY || 0); + const deltaX = e.touches[0].clientX - startX; + const deltaY = e.touches[0].clientY - startY; + + // If horizontal scroll dominates, prevent default to stop drawer gesture + if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 5) { + e.stopPropagation(); + } + }; + + const handleTouchEnd = (e: React.TouchEvent) => { + // Clean up + const target = e.currentTarget as HTMLElement; + delete target.dataset.touchStartX; + delete target.dataset.touchStartY; + }; + + return ( +
+
+ {projects.map((project) => { + const isActive = project.id === activeProjectId; + const status = getProjectStatus(project.path); + const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null; + + return ( + onProjectSwitch(project.id)} + onRemoveProject={onRemoveProject ? () => handleLongPress(project) : undefined} + formatProjectLabel={formatProjectLabel} + /> + ); + })} +
+ + {/* Add project button */} + + + {/* Delete confirmation dialog */} + + + + Remove Project + + Are you sure you want to remove {projectToDelete?.label || formatDirectoryName(projectToDelete?.path || '', homeDirectory)}? + + + + + + + + +
+ ); +} + function CollapsedView({ runningCount, unreadCount, currentSessionTitle, + currentProjectLabel, + currentProjectIcon, + currentProjectColor, onToggle, onNewSession, cornerRadius, + contextUsage, }: { runningCount: number; unreadCount: number; currentSessionTitle: string; + currentProjectLabel?: string; + currentProjectIcon?: string | null; + currentProjectColor?: string | null; onToggle: () => void; onNewSession: () => void; cornerRadius?: number; + contextUsage: SessionContextUsage | null; }) { + const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe(); + return (
-
+
- +
+ + +
); } @@ -349,36 +788,58 @@ function ExpandedView({ runningCount, unreadCount, currentSessionTitle, + currentProjectLabel, + currentProjectIcon, + currentProjectColor, isExpanded, onToggleCollapse, - onToggleExpand, onNewSession, onSessionClick, onSessionDoubleClick, + onProjectSwitch, + onAddProject, + onRemoveProject, getSessionAgentName, getSessionTitle, needsAttention, cornerRadius, + contextUsage, + projects, + activeProjectId, + getProjectStatus, + homeDirectory, }: { sessions: SessionWithStatus[]; currentSessionId: string; runningCount: number; unreadCount: number; currentSessionTitle: string; + currentProjectLabel?: string; + currentProjectIcon?: string | null; + currentProjectColor?: string | null; isExpanded: boolean; onToggleCollapse: () => void; - onToggleExpand: () => void; onNewSession: () => void; onSessionClick: (id: string) => void; onSessionDoubleClick?: () => void; + onProjectSwitch: (projectId: string) => void; + onAddProject: () => void; + onRemoveProject?: (projectId: string) => void; getSessionAgentName: (s: Session) => string; getSessionTitle: (s: Session) => string; needsAttention: (sessionId: string) => boolean; cornerRadius?: number; + contextUsage: SessionContextUsage | null; + projects: ProjectEntry[]; + activeProjectId: string | null; + getProjectStatus: (path: string) => { hasRunning: boolean; hasUnread: boolean }; + homeDirectory: string | null; }) { const containerRef = React.useRef(null); const [collapsedHeight, setCollapsedHeight] = React.useState(null); const [hasMeasured, setHasMeasured] = React.useState(false); + const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe(); + const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); React.useEffect(() => { if (containerRef.current && !hasMeasured && !isExpanded) { @@ -387,8 +848,36 @@ function ExpandedView({ } }, [hasMeasured, isExpanded]); + // Filter sessions by active project + const filteredSessions = React.useMemo(() => { + if (!activeProjectId) return sessions; + + const activeProject = projects.find(p => p.id === activeProjectId); + if (!activeProject) return sessions; + + const projectRoot = normalize(activeProject.path); + const projectDirs = new Set([projectRoot]); + + // Add worktrees + const worktrees = availableWorktreesByProject.get(projectRoot) ?? []; + for (const meta of worktrees) { + const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null; + if (typeof p === 'string' && p.trim()) { + const normalized = normalize(p); + if (normalized) projectDirs.add(normalized); + } + } + + return sessions.filter(session => { + const sessionDir = normalize((session as { directory?: string | null }).directory ?? ''); + return projectDirs.has(sessionDir); + }); + }, [sessions, activeProjectId, projects, availableWorktreesByProject]); + const previewHeight = collapsedHeight ?? undefined; - const displaySessions = hasMeasured || isExpanded ? sessions : sessions.slice(0, 3); + const displaySessions = hasMeasured || isExpanded + ? filteredSessions.filter(s => s.id !== currentSessionId) + : filteredSessions.slice(0, 3); return (
-
-
+ {/* Header row */} +
+
-
+
+ + + -
+ {/* Project switcher bar */} + + + {/* Sessions list */}
- {displaySessions.map((session) => ( - onSessionClick(session.id)} - onDoubleClick={onSessionDoubleClick} - needsAttention={needsAttention} - /> - ))} + {displaySessions.length === 0 ? ( +
+ No sessions in this project +
+ ) : ( + displaySessions.map((session) => ( + onSessionClick(session.id)} + onDoubleClick={onSessionDoubleClick} + needsAttention={needsAttention} + /> + )) + )}
); @@ -462,19 +968,51 @@ export const MobileSessionStatusBar: React.FC = ({ const sessionStatus = useSessionStore((state) => state.sessionStatus); const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates); const setCurrentSession = useSessionStore((state) => state.setCurrentSession); - const createSession = useSessionStore((state) => state.createSession); + const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); + const getContextUsage = useSessionStore((state) => state.getContextUsage); const agents = useConfigStore((state) => state.agents); - const { isMobile, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore(); + const { getCurrentModel } = useConfigStore(); + const { isMobile, showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore(); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); - const [isExpanded, setIsExpanded] = React.useState(false); + + // Project store + const projects = useProjectsStore((state) => state.projects); + const activeProjectId = useProjectsStore((state) => state.activeProjectId); + const setActiveProject = useProjectsStore((state) => state.setActiveProject); + const addProject = useProjectsStore((state) => state.addProject); + const removeProject = useProjectsStore((state) => state.removeProject); + const getActiveProject = useProjectsStore((state) => state.getActiveProject); + + // Directory store + const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus, sessionAttentionStates); const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus, sessionAttentionStates); + const getProjectStatus = useProjectStatus(sessions, sessionStatus, sessionAttentionStates, currentSessionId); const currentSession = sessions.find((s) => s.id === currentSessionId); - const currentSessionTitle = currentSession ? getSessionTitle(currentSession) : 'New session'; + const currentSessionTitle = currentSession + ? getSessionTitle(currentSession) + : '← Swipe here to open sidebars →'; - if (!isMobile || totalCount === 0) { + const activeProject = getActiveProject(); + const currentProjectLabel = activeProject?.label || formatDirectoryName(activeProject?.path || '', homeDirectory); + const currentProjectIcon = activeProject?.icon; + const currentProjectColor = activeProject?.color; + + // Calculate token usage for current session + const currentModel = getCurrentModel(); + const limit = currentModel && typeof currentModel.limit === 'object' && currentModel.limit !== null + ? (currentModel.limit as Record) + : null; + const contextLimit = (limit && typeof limit.context === 'number' ? limit.context : 0); + const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0); + const contextUsage = getContextUsage(contextLimit, outputLimit); + + const [isExpanded, setIsExpanded] = React.useState(false); + const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []); + + if (!isMobile || !showMobileSessionStatusBar || totalCount === 0) { return null; } @@ -489,23 +1027,56 @@ export const MobileSessionStatusBar: React.FC = ({ setActiveMainTab('chat'); }; - const handleCreateSession = async () => { - const newSession = await createSession(); - if (newSession) { - setCurrentSession(newSession.id); - onSessionSwitch?.(newSession.id); + const handleCreateSession = () => { + openNewSessionDraft(); + }; + + const handleProjectSwitch = (projectId: string) => { + if (projectId !== activeProjectId) { + setActiveProject(projectId); } }; + const handleAddProject = () => { + if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) { + sessionEvents.requestDirectoryDialog(); + return; + } + import('@/lib/desktop') + .then(({ requestDirectoryAccess }) => requestDirectoryAccess('')) + .then((result) => { + if (result.success && result.path) { + const added = addProject(result.path, { id: result.projectId }); + if (!added) { + toast.error('Failed to add project', { + description: 'Please select a valid directory.', + }); + } + } else if (result.error && result.error !== 'Directory selection cancelled') { + toast.error('Failed to select directory', { + description: result.error, + }); + } + }) + .catch((error) => { + console.error('Failed to select directory:', error); + toast.error('Failed to select directory'); + }); + }; + if (isMobileSessionStatusBarCollapsed) { return ( setIsMobileSessionStatusBarCollapsed(false)} onNewSession={handleCreateSession} cornerRadius={cornerRadius} + contextUsage={contextUsage} /> ); } @@ -517,19 +1088,29 @@ export const MobileSessionStatusBar: React.FC = ({ runningCount={totalRunning} unreadCount={totalUnread} currentSessionTitle={currentSessionTitle} + currentProjectLabel={currentProjectLabel} + currentProjectIcon={currentProjectIcon} + currentProjectColor={currentProjectColor} isExpanded={isExpanded} onToggleCollapse={() => { setIsMobileSessionStatusBarCollapsed(true); setIsExpanded(false); }} - onToggleExpand={() => setIsExpanded(!isExpanded)} onNewSession={handleCreateSession} onSessionClick={handleSessionClick} onSessionDoubleClick={handleSessionDoubleClick} + onProjectSwitch={handleProjectSwitch} + onAddProject={handleAddProject} + onRemoveProject={removeProject} getSessionAgentName={getSessionAgentName} getSessionTitle={getSessionTitle} needsAttention={needsAttention} cornerRadius={cornerRadius} + contextUsage={contextUsage} + projects={projects} + activeProjectId={activeProjectId} + getProjectStatus={getProjectStatus} + homeDirectory={homeDirectory} /> ); }; diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index a4571492..21b4cc55 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -148,11 +148,6 @@ export const StatusRow: React.FC = ({ return () => document.removeEventListener("mousedown", handleClickOutside); }, [isExpanded]); - // Don't render if nothing to show - if (!hasContent) { - return null; - } - const toggleExpanded = () => setIsExpanded((prev) => !prev); // Abort button for mobile/vscode @@ -193,9 +188,13 @@ export const StatusRow: React.FC = ({ ) : null; + // Don't render if nothing to show + if (!hasContent) { + return null; + } + return (
- {/* Main status row */}
{/* Left: Abort status or Working placeholder */}
diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 0299ac49..e9b369ad 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -15,10 +15,9 @@ import { } from '@/components/ui/dropdown-menu'; import { AnimatedTabs } from '@/components/ui/animated-tabs'; -import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCloseLine, RiFileTextLine, RiFolder6Line, RiFolderAddLine, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiMore2Fill, RiPencilLine, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiSettings3Line, RiStackLine, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react'; +import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiFolderAddLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiMore2Fill, RiPencilLine, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiStackLine, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react'; import { DiffIcon } from '@/components/icons/DiffIcon'; import { useUIStore, type MainTab } from '@/stores/useUIStore'; -import { useUpdateStore } from '@/stores/useUpdateStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; @@ -29,8 +28,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { useDeviceInfo } from '@/lib/device'; import { cn, hasModifier, formatDirectoryName } from '@/lib/utils'; -import { useDiffFileCount } from '@/components/views/DiffView'; -import { McpDropdown, McpDropdownContent } from '@/components/mcp/McpDropdown'; +import { McpDropdownContent } from '@/components/mcp/McpDropdown'; import { McpIcon } from '@/components/icons/McpIcon'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota'; @@ -138,7 +136,19 @@ interface TabConfig { showDot?: boolean; } -export const Header: React.FC = () => { +interface HeaderProps { + onToggleLeftDrawer?: () => void; + onToggleRightDrawer?: () => void; + leftDrawerOpen?: boolean; + rightDrawerOpen?: boolean; +} + +export const Header: React.FC = ({ + onToggleLeftDrawer, + onToggleRightDrawer, + leftDrawerOpen, + rightDrawerOpen, +}) => { const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); const toggleSidebar = useUIStore((state) => state.toggleSidebar); const toggleBottomTerminal = useUIStore((state) => state.toggleBottomTerminal); @@ -147,7 +157,6 @@ export const Header: React.FC = () => { const openContextPlan = useUIStore((state) => state.openContextPlan); const closeContextPanel = useUIStore((state) => state.closeContextPanel); const contextPanelByDirectory = useUIStore((state) => state.contextPanelByDirectory); - const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const activeMainTab = useUIStore((state) => state.activeMainTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); @@ -186,8 +195,6 @@ export const Header: React.FC = () => { const removeProject = useProjectsStore((state) => state.removeProject); const { isMobile } = useDeviceInfo(); - const diffFileCount = useDiffFileCount(); - const updateAvailable = useUpdateStore((state) => state.available); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const setGitHubAuthStatus = useGitHubAuthStore((state) => state.setStatus); @@ -278,6 +285,7 @@ export const Header: React.FC = () => { const [desktopServicesTab, setDesktopServicesTab] = React.useState<'instance' | 'usage' | 'mcp'>( isDesktopApp ? 'instance' : 'usage' ); + const [mobileServicesTab, setMobileServicesTab] = React.useState<'usage' | 'mcp'>('usage'); useEffect(() => { if (!isDesktopApp && desktopServicesTab === 'instance') { setDesktopServicesTab('usage'); @@ -865,14 +873,6 @@ export const Header: React.FC = () => { toggleSidebar(); }, [blurActiveElement, isMobile, isSessionSwitcherOpen, setSessionSwitcherOpen, toggleSidebar]); - const handleOpenSettings = React.useCallback(() => { - if (isMobile) { - blurActiveElement(); - } - setSessionSwitcherOpen(false); - setSettingsDialogOpen(true); - }, [blurActiveElement, isMobile, setSessionSwitcherOpen, setSettingsDialogOpen]); - const handleOpenContextPanel = React.useCallback(() => { const directory = normalize(openDirectory || ''); if (!directory) { @@ -1030,17 +1030,11 @@ export const Header: React.FC = () => { label: 'Terminal', icon: RiTerminalBoxLine, }, - { - id: 'git', - label: 'Git', - icon: RiGitBranchLine, - showDot: diffFileCount > 0, - }, ); } return base; - }, [diffFileCount, isMobile, showPlanTab]); + }, [isMobile, showPlanTab]); const shortcutLabel = React.useCallback((actionId: string) => { return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)); @@ -2126,11 +2120,24 @@ export const Header: React.FC = () => { ); const renderMobile = () => ( -
+
- {/* Show back button when sessions sidebar is open, otherwise show sessions toggle */} - {isSessionSwitcherOpen ? ( + {/* Use drawer toggle when onToggleLeftDrawer is provided, otherwise use legacy session switcher */} + {onToggleLeftDrawer ? ( + ) : isSessionSwitcherOpen ? ( + ) : ( )} - {!isSessionSwitcherOpen && contextUsage && contextUsage.totalTokens > 0 && activeMainTab === 'chat' && ( - - )} + {isSessionSwitcherOpen && ( Sessions )} @@ -2163,66 +2162,71 @@ export const Header: React.FC = () => { {/* Hide tabs and right-side buttons when sessions sidebar is open */} {!isSessionSwitcherOpen && ( -
-
-
-
- {tabs.map((tab) => { - const isActive = activeMainTab === tab.id; - const isDiffTab = tab.icon === 'diff'; - const Icon = isDiffTab ? null : (tab.icon as RemixiconComponentType); - return ( - - - - - -

{tab.label}

-
-
- ); - })} + <> +
+
+
+
+ {tabs.map((tab) => { + const isActive = activeMainTab === tab.id; + const isDiffTab = tab.icon === 'diff'; + const Icon = isDiffTab ? null : (tab.icon as RemixiconComponentType); + return ( + + + + + +

{tab.label}

+
+
+ ); + })} +
+
+
- +
+ {/* Mobile Services Menu (Usage + MCP) */} { @@ -2237,16 +2241,15 @@ export const Header: React.FC = () => { -

Rate limits

+

Services

{ className="h-dvh w-[100vw] max-h-none rounded-none border-0 p-0 overflow-hidden" >
-
+
- Rate limits -
-
- - -
- - -
-
-
- Last updated {formatTime(quotaLastUpdated)} + + value={mobileServicesTab} + onValueChange={(value) => { + setMobileServicesTab(value); + if (value === 'usage' && quotaResults.length === 0) { + fetchAllQuotas(); + } + }} + tabs={[ + { value: 'usage', label: 'Usage', icon: RiTimerLine }, + { value: 'mcp', label: 'MCP', icon: RiCommandLine }, + ]} + className="rounded-md" + /> +
-
- {!hasRateLimits && ( -
- No rate limits available. -
- )} - {rateLimitGroups.map((group) => ( - -
- - {group.providerName} -
- {group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) && ( -
- {group.error ?? 'No rate limits reported.'} + + {mobileServicesTab === 'mcp' && ( + + )} + + {mobileServicesTab === 'usage' && ( +
+
+
+
+ Rate limits + + Last updated {formatTime(quotaLastUpdated)} +
- )} - {group.entries.map(([label, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label); - const expectedMarker = paceInfo?.dailyAllocationPercent != null - ? (quotaDisplayMode === 'remaining' - ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) - : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) - : null; - return ( -
-
- - {formatWindowLabel(label)} - - - {formatPercent(displayPercent)} - -
- - {paceInfo && ( -
- +
+ + value={quotaDisplayMode} + onValueChange={handleDisplayModeChange} + tabs={quotaDisplayTabs} + size="sm" + className="w-[10.5rem]" + /> + +
+
+
+ {!hasRateLimits && ( + event.preventDefault()} + > + No rate limits available. + + )} + {rateLimitGroups.map((group) => ( + + + + {group.providerName} + + + {group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? ( + event.preventDefault()} + > + + {group.error ?? 'No rate limits reported.'} + + + ) : ( + <> + {group.entries.map(([label, window]) => { + const displayPercent = quotaDisplayMode === 'remaining' + ? window.remainingPercent + : window.usedPercent; + const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label); + const expectedMarker = paceInfo?.dailyAllocationPercent != null + ? (quotaDisplayMode === 'remaining' + ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) + : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) + : null; + return ( + event.preventDefault()} + > + + + + {formatWindowLabel(label)} + {(window.resetAfterFormatted ?? window.resetAtFormatted) ? ( + + {window.resetAfterFormatted ?? window.resetAtFormatted} + + ) : null} + + + {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} + + + + {paceInfo && ( +
+ +
+ )} +
+
+ ); + })} + + {group.modelFamilies && group.modelFamilies.length > 0 && ( +
+ {group.modelFamilies.map((family) => { + const providerExpandedFamilies = expandedFamilies[group.providerId] ?? []; + const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other'); + + return ( + toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')} + > + + + {family.familyLabel} + + {isExpanded ? ( + + ) : ( + + )} + + +
+ {family.models.map(([modelName, window]) => { + const displayPercent = quotaDisplayMode === 'remaining' + ? window.remainingPercent + : window.usedPercent; + const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds); + const expectedMarker = paceInfo?.dailyAllocationPercent != null + ? (quotaDisplayMode === 'remaining' + ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) + : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) + : null; + return ( +
+
+ + {getDisplayModelName(modelName)} + + {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} + + + + {paceInfo && ( +
+ +
+ )} +
+
+ ); + })} +
+
+
+ ); + })}
)} -
- {window.resetAfterFormatted ?? window.resetAtFormatted ?? ''} -
-
- ); - })} - {/* Model families with collapsible sections */} - {group.modelFamilies && group.modelFamilies.length > 0 && ( -
- {group.modelFamilies.map((family) => { - const providerExpandedFamilies = expandedFamilies[group.providerId] ?? []; - const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other'); - - return ( - toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')} - > - - - {family.familyLabel} - - {isExpanded ? ( - - ) : ( - - )} - - -
- {family.models.map(([modelName, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds); - const expectedMarker = paceInfo?.dailyAllocationPercent != null - ? (quotaDisplayMode === 'remaining' - ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) - : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) - : null; - return ( -
-
- - {getDisplayModelName(modelName)} - - - {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} - -
- - {paceInfo && ( -
- -
- )} -
- ); - })} -
-
-
- ); - })} -
- )} - - ))} -
+ + )} + + ))} +
+ )}
- - - - - -

{updateAvailable ? `Settings (Update available) (${shortcutLabel('open_settings')})` : `Settings (${shortcutLabel('open_settings')})`}

-
-
-
+ {onToggleRightDrawer ? ( + + + + + +

{rightDrawerOpen ? 'Close git sidebar' : 'Open git sidebar'}

+
+
+ ) : null}
-
+ )}
); diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 11bdf700..74e94026 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -1,4 +1,6 @@ -import React from 'react'; +import React, { useRef, useEffect } from 'react'; +import { motion, useMotionValue, animate } from 'motion/react'; +import { RiSettings3Line } from '@remixicon/react'; import { Header } from './Header'; import { BottomTerminalDock } from './BottomTerminalDock'; import { Sidebar } from './Sidebar'; @@ -13,16 +15,20 @@ import { SessionSidebar } from '@/components/session/SessionSidebar'; import { SessionDialogs } from '@/components/session/SessionDialogs'; import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider'; import { MultiRunLauncher } from '@/components/multirun'; +import { DrawerProvider } from '@/contexts/DrawerContext'; import { useUIStore } from '@/stores/useUIStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; import { useDeviceInfo } from '@/lib/device'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; -import { useEdgeSwipe } from '@/hooks/useEdgeSwipe'; +import { useDrawerSwipe } from '@/hooks/useDrawerSwipe'; import { cn } from '@/lib/utils'; import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow } from '@/components/views'; +// 移动端抽屉宽度(占屏幕比例) +const MOBILE_DRAWER_WIDTH_PERCENT = 85; + const normalizeDirectoryKey = (value: string): string => { if (!value) return ''; @@ -42,6 +48,26 @@ const normalizeDirectoryKey = (value: string): string => { return normalized; }; +const MobileDrawerGestureSurface: React.FC<{ + className?: string; + style?: React.CSSProperties; + children: React.ReactNode; +}> = ({ className, style, children }) => { + const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe(); + + return ( +
+ {children} +
+ ); +}; + export const MainLayout: React.FC = () => { const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140; const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220; @@ -78,7 +104,64 @@ export const MainLayout: React.FC = () => { const bottomTerminalAutoClosedRef = React.useRef(false); const leftSidebarAutoClosedByContextRef = React.useRef(false); - useEdgeSwipe({ enabled: true }); + // 移动端抽屉状态 + const [mobileLeftDrawerOpen, setMobileLeftDrawerOpen] = React.useState(false); + const mobileRightDrawerOpenRef = React.useRef(false); + + // 左抽屉 motion value + const leftDrawerX = useMotionValue(0); + const leftDrawerWidth = useRef(0); + + // 右抽屉 motion value + const rightDrawerX = useMotionValue(0); + const rightDrawerWidth = useRef(0); + + // 计算抽屉宽度 + useEffect(() => { + if (isMobile) { + leftDrawerWidth.current = window.innerWidth * (MOBILE_DRAWER_WIDTH_PERCENT / 100); + rightDrawerWidth.current = window.innerWidth * (MOBILE_DRAWER_WIDTH_PERCENT / 100); + } + }, [isMobile]); + + // 同步左抽屉 state 和 motion value + useEffect(() => { + if (!isMobile) return; + const targetX = mobileLeftDrawerOpen ? 0 : -leftDrawerWidth.current; + animate(leftDrawerX, targetX, { + type: "spring", + stiffness: 400, + damping: 35, + mass: 0.8 + }); + }, [mobileLeftDrawerOpen, isMobile, leftDrawerX]); + + // 同步右抽屉 state 和 motion value + useEffect(() => { + if (!isMobile) return; + mobileRightDrawerOpenRef.current = isRightSidebarOpen; + const targetX = isRightSidebarOpen ? 0 : rightDrawerWidth.current; + animate(rightDrawerX, targetX, { + type: "spring", + stiffness: 400, + damping: 35, + mass: 0.8 + }); + }, [isMobile, isRightSidebarOpen, rightDrawerX]); + + // 同步 session switcher 状态到左抽屉 (单向同步,避免循环) + useEffect(() => { + if (isMobile) { + setMobileLeftDrawerOpen(isSessionSwitcherOpen); + } + }, [isSessionSwitcherOpen, isMobile]); + + // 同步右抽屉和 git sidebar 状态 + useEffect(() => { + if (isMobile) { + mobileRightDrawerOpenRef.current = isRightSidebarOpen; + } + }, [isRightSidebarOpen, isMobile]); // Trigger update check 3 seconds after mount (for both mobile and desktop) const checkForUpdates = useUpdateStore((state) => state.checkForUpdates); @@ -502,21 +585,178 @@ export const MainLayout: React.FC = () => { {isMobile ? ( - <> - {/* Mobile: Header + content with drill-down pattern */} - {!(isSettingsDialogOpen || isMultiRunLauncherOpen) &&
} -
{ + if (isRightSidebarOpen) { + setRightSidebarOpen(false); + } + setMobileLeftDrawerOpen(!mobileLeftDrawerOpen); + }, + toggleRightDrawer: () => { + if (mobileLeftDrawerOpen) { + setMobileLeftDrawerOpen(false); + } + setRightSidebarOpen(!isRightSidebarOpen); + }, + leftDrawerX, + rightDrawerX, + leftDrawerWidth, + rightDrawerWidth, + setMobileLeftDrawerOpen, + setRightSidebarOpen, + }}> + {/* Mobile: Header + Drawer 模式 */} + {!(isSettingsDialogOpen || isMultiRunLauncherOpen) &&
{ + if (isRightSidebarOpen) { + setRightSidebarOpen(false); + } + setMobileLeftDrawerOpen(!mobileLeftDrawerOpen); + }} + onToggleRightDrawer={() => { + if (mobileLeftDrawerOpen) { + setMobileLeftDrawerOpen(false); + } + setRightSidebarOpen(!isRightSidebarOpen); + }} + leftDrawerOpen={mobileLeftDrawerOpen} + rightDrawerOpen={isRightSidebarOpen} + />} + + {/* 遮罩层 */} + { + setMobileLeftDrawerOpen(false); + setRightSidebarOpen(false); + }} + aria-label="Close drawer" + /> + + {/* 左抽屉(Session) */} + { + const drawerWidthPx = leftDrawerWidth.current || window.innerWidth * 0.85; + const threshold = drawerWidthPx * 0.3; + const velocityThreshold = 500; + const currentX = leftDrawerX.get(); + + const shouldClose = info.offset.x < -threshold || info.velocity.x < -velocityThreshold; + const shouldOpen = info.offset.x > threshold || info.velocity.x > velocityThreshold; + + if (shouldClose) { + leftDrawerX.set(-drawerWidthPx); + setMobileLeftDrawerOpen(false); + } else if (shouldOpen) { + leftDrawerX.set(0); + setMobileLeftDrawerOpen(true); + } else { + if (currentX > -drawerWidthPx / 2) { + leftDrawerX.set(0); + } else { + leftDrawerX.set(-drawerWidthPx); + } + } + }} className={cn( - 'flex flex-1 overflow-hidden', + 'fixed left-0 top-0 z-50 h-full bg-transparent', + 'cursor-grab active:cursor-grabbing' + )} + aria-hidden={!mobileLeftDrawerOpen} + > +
+
+ + + +
+
+ +
+
+
+ + {/* 右抽屉(Git) */} + { + const drawerWidthPx = rightDrawerWidth.current || window.innerWidth * 0.85; + const threshold = drawerWidthPx * 0.3; + const velocityThreshold = 500; + const currentX = rightDrawerX.get(); + + const shouldClose = info.offset.x > threshold || info.velocity.x > velocityThreshold; + const shouldOpen = info.offset.x < -threshold || info.velocity.x < -velocityThreshold; + + if (shouldClose) { + rightDrawerX.set(drawerWidthPx); + setRightSidebarOpen(false); + } else if (shouldOpen) { + rightDrawerX.set(0); + setRightSidebarOpen(true); + } else { + if (currentX < drawerWidthPx / 2) { + rightDrawerX.set(0); + } else { + rightDrawerX.set(drawerWidthPx); + } + } + }} + className={cn( + 'fixed right-0 top-0 z-50 h-full bg-transparent', + 'cursor-grab active:cursor-grabbing' + )} + aria-hidden={!isRightSidebarOpen} + > +
+ + + +
+
+ + {/* 主内容区(固定) */} + - {/* Mobile drill-down: show sessions sidebar OR main content */} -
- -
-
+
@@ -526,7 +766,7 @@ export const MainLayout: React.FC = () => {
)} -
+ {/* Mobile multi-run launcher: full screen */} {isMultiRunLauncherOpen && ( @@ -547,7 +787,7 @@ export const MainLayout: React.FC = () => { setSettingsDialogOpen(false)} />
)} - + ) : ( <> {/* Desktop: Header always on top, then Sidebar + Content below */} @@ -574,7 +814,7 @@ export const MainLayout: React.FC = () => {
- +
diff --git a/packages/ui/src/components/layout/RightSidebar.tsx b/packages/ui/src/components/layout/RightSidebar.tsx index 53146d0b..e91bf3e2 100644 --- a/packages/ui/src/components/layout/RightSidebar.tsx +++ b/packages/ui/src/components/layout/RightSidebar.tsx @@ -7,11 +7,10 @@ const RIGHT_SIDEBAR_MAX_WIDTH = 860; interface RightSidebarProps { isOpen: boolean; - isMobile: boolean; children: React.ReactNode; } -export const RightSidebar: React.FC = ({ isOpen, isMobile, children }) => { +export const RightSidebar: React.FC = ({ isOpen, children }) => { const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth); const setRightSidebarWidth = useUIStore((state) => state.setRightSidebarWidth); const [isResizing, setIsResizing] = React.useState(false); @@ -19,7 +18,7 @@ export const RightSidebar: React.FC = ({ isOpen, isMobile, ch const startWidthRef = React.useRef(rightSidebarWidth || 420); React.useEffect(() => { - if (isMobile || !isResizing) { + if (!isResizing) { return; } @@ -43,17 +42,7 @@ export const RightSidebar: React.FC = ({ isOpen, isMobile, ch window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); }; - }, [isMobile, isResizing, setRightSidebarWidth]); - - React.useEffect(() => { - if (isMobile && isResizing) { - setIsResizing(false); - } - }, [isMobile, isResizing]); - - if (isMobile) { - return null; - } + }, [isResizing, setRightSidebarWidth]); const appliedWidth = isOpen ? Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || 420)) diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 36f39f16..b9c29731 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -102,9 +102,9 @@ const VisualSectionContent: React.FC = () => { return ; }; -// Chat section: Default Tool Output, Diff layout, Show reasoning traces, Queue mode, Persist draft +// Chat section: Default Tool Output, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft const ChatSectionContent: React.FC = () => { - return ; + return ; }; // Sessions section: Default model & agent, Session retention, Memory limits diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 86194a8b..316a7bf0 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -83,7 +83,7 @@ const DIFF_VIEW_MODE_OPTIONS: Option<'single' | 'stacked'>[] = [ }, ]; -export type VisibleSetting = 'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'toolOutput' | 'diffLayout' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys' | 'persistDraft'; +export type VisibleSetting = 'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'toolOutput' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys' | 'persistDraft'; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ @@ -119,6 +119,8 @@ export const OpenChamberVisualSettings: React.FC const setQueueMode = useMessageQueueStore(state => state.setQueueMode); const persistChatDraft = useUIStore(state => state.persistChatDraft); const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft); + const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar); + const setShowMobileSessionStatusBar = useUIStore(state => state.setShowMobileSessionStatusBar); const { themeMode, setThemeMode, @@ -171,6 +173,7 @@ export const OpenChamberVisualSettings: React.FC const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset'); const hasBehaviorSettings = shouldShow('toolOutput') || shouldShow('diffLayout') + || shouldShow('mobileStatusBar') || shouldShow('dotfiles') || shouldShow('reasoning') || shouldShow('queueMode') @@ -545,8 +548,31 @@ export const OpenChamberVisualSettings: React.FC )} - {(shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('reasoning') || shouldShow('textJustificationActivity')) && ( + {(shouldShow('mobileStatusBar') || shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('reasoning') || shouldShow('textJustificationActivity')) && (
+ {shouldShow('mobileStatusBar') && ( +
setShowMobileSessionStatusBar(!showMobileSessionStatusBar)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + setShowMobileSessionStatusBar(!showMobileSessionStatusBar); + } + }} + > + + Show Mobile Status Bar +
+ )} + {shouldShow('dotfiles') && !isVSCodeRuntime() && (
= ({ mode = 'full' }) => { fetchIdentity, setLogMaxCount, } = useGitStore(); + const isMobile = useUIStore((state) => state.isMobile); + const openContextDiff = useUIStore((state) => state.openContextDiff); + const navigateToDiff = useUIStore((state) => state.navigateToDiff); + const setRightSidebarOpen = useUIStore((state) => state.setRightSidebarOpen); const initialSnapshot = React.useMemo(() => { if (!currentDirectory) return null; @@ -1713,11 +1717,14 @@ export const GitView: React.FC = ({ mode = 'full' }) => { onSelectAll={selectAll} onClearSelection={clearSelection} onViewDiff={(path) => { - if (isSidebarMode && currentDirectory) { - useUIStore.getState().openContextDiff(currentDirectory, path); + if (isSidebarMode && currentDirectory && !isMobile) { + openContextDiff(currentDirectory, path); return; } - useUIStore.getState().navigateToDiff(path); + navigateToDiff(path); + if (isSidebarMode && isMobile) { + setRightSidebarOpen(false); + } }} onRevertFile={handleRevertFile} /> diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 2058549b..b49a34e8 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -335,6 +335,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile const openPage = React.useCallback((slug: SettingsPageSlug) => { setSettingsPage(slug); + autoNavSlugRef.current = slug; if (!isMobile) { return; } diff --git a/packages/ui/src/contexts/DrawerContext.tsx b/packages/ui/src/contexts/DrawerContext.tsx new file mode 100644 index 00000000..77ed7594 --- /dev/null +++ b/packages/ui/src/contexts/DrawerContext.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import type { MotionValue } from 'motion/react'; + +export interface DrawerContextValue { + leftDrawerOpen: boolean; + rightDrawerOpen: boolean; + toggleLeftDrawer: () => void; + toggleRightDrawer: () => void; + // Motion values for real-time drawer dragging + leftDrawerX: MotionValue; + rightDrawerX: MotionValue; + leftDrawerWidth: React.MutableRefObject; + rightDrawerWidth: React.MutableRefObject; + setMobileLeftDrawerOpen: (open: boolean) => void; + setRightSidebarOpen: (open: boolean) => void; +} + +const DrawerContext = React.createContext(null); + +export const DrawerProvider: React.FC<{ + children: React.ReactNode; + value: DrawerContextValue; +}> = ({ children, value }) => { + return ( + + {children} + + ); +}; + +// eslint-disable-next-line react-refresh/only-export-components +export const useDrawer = (): DrawerContextValue => { + const context = React.useContext(DrawerContext); + if (!context) { + throw new Error('useDrawer must be used within a DrawerProvider'); + } + return context; +}; diff --git a/packages/ui/src/hooks/useDrawerSwipe.ts b/packages/ui/src/hooks/useDrawerSwipe.ts new file mode 100644 index 00000000..5b919c0b --- /dev/null +++ b/packages/ui/src/hooks/useDrawerSwipe.ts @@ -0,0 +1,156 @@ +import React from 'react'; +import { animate } from 'motion/react'; +import { useDrawer } from '@/contexts/DrawerContext'; + +export function useDrawerSwipe() { + const drawer = useDrawer(); + const touchStartXRef = React.useRef(0); + const touchStartYRef = React.useRef(0); + const isHorizontalSwipeRef = React.useRef(null); + const isDraggingDrawerRef = React.useRef<'left' | 'right' | null>(null); + + const handleTouchStart = React.useCallback((e: React.TouchEvent) => { + touchStartXRef.current = e.touches[0].clientX; + touchStartYRef.current = e.touches[0].clientY; + isHorizontalSwipeRef.current = null; + isDraggingDrawerRef.current = null; + }, []); + + const handleTouchMove = React.useCallback((e: React.TouchEvent) => { + const currentX = e.touches[0].clientX; + const currentY = e.touches[0].clientY; + const deltaX = currentX - touchStartXRef.current; + const deltaY = currentY - touchStartYRef.current; + + if (isHorizontalSwipeRef.current === null) { + if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) { + isHorizontalSwipeRef.current = Math.abs(deltaX) > Math.abs(deltaY); + } + } + + if (isHorizontalSwipeRef.current === true) { + e.preventDefault(); + + const leftDrawerWidthPx = drawer.leftDrawerWidth.current || window.innerWidth * 0.85; + const rightDrawerWidthPx = drawer.rightDrawerWidth.current || window.innerWidth * 0.85; + + if (isDraggingDrawerRef.current === null) { + if (drawer.leftDrawerOpen && deltaX > 10) { + isDraggingDrawerRef.current = 'left'; + } else if (drawer.rightDrawerOpen && deltaX < -10) { + isDraggingDrawerRef.current = 'right'; + } else if (!drawer.leftDrawerOpen && !drawer.rightDrawerOpen) { + if (deltaX > 30) { + isDraggingDrawerRef.current = 'left'; + } else if (deltaX < -30) { + isDraggingDrawerRef.current = 'right'; + } + } + } + + if (isDraggingDrawerRef.current === 'left') { + if (drawer.leftDrawerOpen) { + const progress = Math.max(0, Math.min(1, deltaX / leftDrawerWidthPx)); + drawer.leftDrawerX.set(-leftDrawerWidthPx * (1 - progress)); + } else { + const progress = Math.max(0, Math.min(1, deltaX / leftDrawerWidthPx)); + drawer.leftDrawerX.set(-leftDrawerWidthPx + (leftDrawerWidthPx * progress)); + } + } + + if (isDraggingDrawerRef.current === 'right') { + if (drawer.rightDrawerOpen) { + const progress = Math.max(0, Math.min(1, -deltaX / rightDrawerWidthPx)); + drawer.rightDrawerX.set(rightDrawerWidthPx * (1 - progress)); + } else { + const progress = Math.max(0, Math.min(1, -deltaX / rightDrawerWidthPx)); + drawer.rightDrawerX.set(rightDrawerWidthPx - (rightDrawerWidthPx * progress)); + } + } + } + }, [drawer]); + + const handleTouchEnd = React.useCallback((e: React.TouchEvent) => { + if (isHorizontalSwipeRef.current !== true) return; + + const endX = e.changedTouches[0].clientX; + const deltaX = endX - touchStartXRef.current; + const velocityThreshold = 500; + const progressThreshold = 0.3; + + const leftDrawerWidthPx = drawer.leftDrawerWidth.current || window.innerWidth * 0.85; + const rightDrawerWidthPx = drawer.rightDrawerWidth.current || window.innerWidth * 0.85; + + if (isDraggingDrawerRef.current === 'left') { + const isOpen = drawer.leftDrawerOpen; + const currentX = drawer.leftDrawerX.get(); + const progress = isOpen + ? 1 - Math.abs(currentX) / leftDrawerWidthPx + : 1 + currentX / leftDrawerWidthPx; + + const shouldComplete = progress > progressThreshold || Math.abs(deltaX * 10) > velocityThreshold; + + if (shouldComplete) { + const targetX = isOpen ? -leftDrawerWidthPx : 0; + animate(drawer.leftDrawerX, targetX, { + type: 'spring', + stiffness: 400, + damping: 35, + mass: 0.8, + }); + drawer.setMobileLeftDrawerOpen(!isOpen); + } else { + const targetX = isOpen ? 0 : -leftDrawerWidthPx; + animate(drawer.leftDrawerX, targetX, { + type: 'spring', + stiffness: 400, + damping: 35, + mass: 0.8, + }); + } + + isDraggingDrawerRef.current = null; + return; + } + + if (isDraggingDrawerRef.current === 'right') { + const isOpen = drawer.rightDrawerOpen; + const currentX = drawer.rightDrawerX.get(); + const progress = isOpen + ? 1 - Math.abs(currentX) / rightDrawerWidthPx + : 1 - currentX / rightDrawerWidthPx; + + const shouldComplete = progress > progressThreshold || Math.abs(deltaX * 10) > velocityThreshold; + + if (shouldComplete) { + const targetX = isOpen ? rightDrawerWidthPx : 0; + animate(drawer.rightDrawerX, targetX, { + type: 'spring', + stiffness: 400, + damping: 35, + mass: 0.8, + }); + drawer.setRightSidebarOpen(!isOpen); + } else { + const targetX = isOpen ? 0 : rightDrawerWidthPx; + animate(drawer.rightDrawerX, targetX, { + type: 'spring', + stiffness: 400, + damping: 35, + mass: 0.8, + }); + } + + isDraggingDrawerRef.current = null; + return; + } + + isHorizontalSwipeRef.current = null; + }, [drawer]); + + return { + handleTouchStart, + handleTouchMove, + handleTouchEnd, + }; +} diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 8e7b018c..528abfee 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -220,7 +220,9 @@ interface UIStore { showTerminalQuickKeysOnDesktop: boolean; persistChatDraft: boolean; + showMobileSessionStatusBar: boolean; isMobileSessionStatusBarCollapsed: boolean; + viewPagerPage: 'left' | 'center' | 'right'; isExpandedInput: boolean; @@ -313,7 +315,9 @@ interface UIStore { setSummaryLength: (value: number) => void; setMaxLastMessageLength: (value: number) => void; setPersistChatDraft: (value: boolean) => void; + setShowMobileSessionStatusBar: (value: boolean) => void; setIsMobileSessionStatusBarCollapsed: (value: boolean) => void; + setViewPagerPage: (page: 'left' | 'center' | 'right') => void; toggleExpandedInput: () => void; setExpandedInput: (value: boolean) => void; openMultiRunLauncher: () => void; @@ -412,6 +416,7 @@ export const useUIStore = create()( showTerminalQuickKeysOnDesktop: false, persistChatDraft: true, + showMobileSessionStatusBar: true, isMobileSessionStatusBarCollapsed: false, isExpandedInput: false, shortcutOverrides: {}, @@ -1194,9 +1199,23 @@ export const useUIStore = create()( setPersistChatDraft: (value) => { set({ persistChatDraft: value }); }, + setShowMobileSessionStatusBar: (value) => { + set({ showMobileSessionStatusBar: value }); + }, setIsMobileSessionStatusBarCollapsed: (value) => { set({ isMobileSessionStatusBarCollapsed: value }); }, + viewPagerPage: 'center', + setViewPagerPage: (page: 'left' | 'center' | 'right') => { + set({ viewPagerPage: page }); + if (page === 'left') { + set({ isSessionSwitcherOpen: true, isRightSidebarOpen: false }); + } else if (page === 'right') { + set({ isRightSidebarOpen: true, isSessionSwitcherOpen: false }); + } else { + set({ isSessionSwitcherOpen: false, isRightSidebarOpen: false }); + } + }, setShortcutOverride: (actionId, combo) => { set((state) => ({ @@ -1347,6 +1366,7 @@ export const useUIStore = create()( summaryLength: state.summaryLength, maxLastMessageLength: state.maxLastMessageLength, persistChatDraft: state.persistChatDraft, + showMobileSessionStatusBar: state.showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed, shortcutOverrides: state.shortcutOverrides, }) diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index bb96429e..31855638 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -273,6 +273,12 @@ padding-bottom: var(--oc-safe-area-bottom-visual) !important; } + /* Drawer safe area - only top/bottom padding, no positioning */ + .drawer-safe-area { + padding-top: var(--oc-safe-area-top); + padding-bottom: var(--oc-safe-area-bottom-visual); + } + /* iOS keyboard home indicator safe area - used when keyboard is open */ .ios-keyboard-safe-area { padding-bottom: calc(var(--oc-keyboard-home-indicator, 34px) + var(--oc-safe-area-bottom-visual, 0px)) !important;