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 <jovines@qq.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Jovines
2026-02-24 11:44:43 +02:00
committed by GitHub
co-authored by Jovines Bohdan Triapitsyn
parent 7a11867a19
commit 25d616f009
15 changed files with 1564 additions and 426 deletions
+328 -296
View File
@@ -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<HeaderProps> = ({
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 = () => (
<div className="app-region-drag relative flex items-center justify-between gap-2 px-3 py-2 select-none">
<div className="app-region-drag relative flex items-center gap-2 px-3 py-2 select-none">
<div className="flex items-center gap-2 shrink-0">
{/* 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 ? (
<button
type="button"
onClick={onToggleLeftDrawer}
className={cn(
headerIconButtonClass,
leftDrawerOpen && 'bg-interactive-selection text-interactive-selection-foreground'
)}
aria-label={leftDrawerOpen ? 'Close sessions' : 'Open sessions'}
>
<RiLayoutLeftLine className="h-5 w-5" />
</button>
) : isSessionSwitcherOpen ? (
<button
type="button"
onClick={() => setSessionSwitcherOpen(false)}
className="app-region-no-drag h-9 w-9 p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md active:bg-interactive-active"
aria-label="Back"
@@ -2139,6 +2146,7 @@ export const Header: React.FC = () => {
</button>
) : (
<button
type="button"
onClick={handleOpenSessionSwitcher}
className="app-region-no-drag h-9 w-9 p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md active:bg-interactive-active"
aria-label="Open sessions"
@@ -2146,16 +2154,7 @@ export const Header: React.FC = () => {
<RiPlayListAddLine className="h-5 w-5" />
</button>
)}
{!isSessionSwitcherOpen && contextUsage && contextUsage.totalTokens > 0 && activeMainTab === 'chat' && (
<ContextUsageDisplay
totalTokens={contextUsage.totalTokens}
percentage={contextUsage.percentage}
contextLimit={contextUsage.contextLimit}
outputLimit={contextUsage.outputLimit ?? 0}
size="compact"
isMobile={true}
/>
)}
{isSessionSwitcherOpen && (
<span className="typography-ui-label font-semibold text-foreground">Sessions</span>
)}
@@ -2163,66 +2162,71 @@ export const Header: React.FC = () => {
{/* Hide tabs and right-side buttons when sessions sidebar is open */}
{!isSessionSwitcherOpen && (
<div className="app-region-no-drag flex min-w-0 flex-1 items-center">
<div className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden scrollbar-hidden touch-pan-x overscroll-x-contain">
<div className="flex w-max items-center gap-1 pr-1">
<div
className="flex items-center gap-0.5 rounded-lg bg-[var(--surface-muted)]/50 p-0.5"
role="tablist"
aria-label="Main navigation"
>
{tabs.map((tab) => {
const isActive = activeMainTab === tab.id;
const isDiffTab = tab.icon === 'diff';
const Icon = isDiffTab ? null : (tab.icon as RemixiconComponentType);
return (
<Tooltip key={tab.id} delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
if (isMobile) {
blurActiveElement();
}
setActiveMainTab(tab.id);
}}
aria-label={tab.label}
aria-selected={isActive}
role="tab"
className={cn(
headerIconButtonClass,
'relative rounded-lg',
isActive && 'bg-interactive-selection text-interactive-selection-foreground'
)}
>
{isDiffTab ? (
<DiffIcon className="h-5 w-5" />
) : Icon ? (
<Icon className="h-5 w-5" />
) : null}
{tab.badge !== undefined && tab.badge > 0 && (
<span className="absolute -top-1 -right-1 text-[10px] font-semibold text-primary">
{tab.badge}
</span>
)}
{tab.showDot && (
<span
className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-primary"
aria-label="Changes available"
/>
)}
</button>
</TooltipTrigger>
<TooltipContent>
<p>{tab.label}</p>
</TooltipContent>
</Tooltip>
);
})}
<>
<div className="app-region-no-drag flex min-w-0 flex-1 items-center">
<div className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden scrollbar-hidden touch-pan-x overscroll-x-contain">
<div className="flex w-max items-center gap-1 pr-1">
<div
className="flex items-center gap-0.5 rounded-lg bg-[var(--surface-muted)]/50 p-0.5"
role="tablist"
aria-label="Main navigation"
>
{tabs.map((tab) => {
const isActive = activeMainTab === tab.id;
const isDiffTab = tab.icon === 'diff';
const Icon = isDiffTab ? null : (tab.icon as RemixiconComponentType);
return (
<Tooltip key={tab.id} delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
if (isMobile) {
blurActiveElement();
}
setActiveMainTab(tab.id);
}}
aria-label={tab.label}
aria-selected={isActive}
role="tab"
className={cn(
headerIconButtonClass,
'relative rounded-lg',
isActive && 'bg-interactive-selection text-interactive-selection-foreground'
)}
>
{isDiffTab ? (
<DiffIcon className="h-5 w-5" />
) : Icon ? (
<Icon className="h-5 w-5" />
) : null}
{tab.badge !== undefined && tab.badge > 0 && (
<span className="absolute -top-1 -right-1 text-[10px] font-semibold text-primary">
{tab.badge}
</span>
)}
{tab.showDot && (
<span
className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-primary"
aria-label="Changes available"
/>
)}
</button>
</TooltipTrigger>
<TooltipContent>
<p>{tab.label}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
</div>
</div>
<McpDropdown headerIconButtonClass={headerIconButtonClass} />
<div className="flex items-center gap-1 shrink-0">
{/* Mobile Services Menu (Usage + MCP) */}
<DropdownMenu
open={isMobileRateLimitsOpen}
onOpenChange={(open) => {
@@ -2237,16 +2241,15 @@ export const Header: React.FC = () => {
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="View rate limits"
aria-label="View services"
className={headerIconButtonClass}
disabled={isQuotaLoading}
>
<RiTimerLine className="h-5 w-5" />
<RiStackLine className="h-5 w-5" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Rate limits</p>
<p>Services</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent
@@ -2255,216 +2258,245 @@ export const Header: React.FC = () => {
className="h-dvh w-[100vw] max-h-none rounded-none border-0 p-0 overflow-hidden"
>
<div className="flex h-full flex-col bg-[var(--surface-elevated)]">
<div className="sticky top-0 z-20 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)]">
<div className="sticky top-0 z-20 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-2">
<div className="flex items-center justify-between gap-2 px-3 py-3">
<span className="typography-ui-header font-semibold text-foreground">Rate limits</span>
<div className="flex items-center gap-2">
<div className="flex items-center rounded-md border border-[var(--interactive-border)] p-0.5">
<button
type="button"
className={cn(
'px-1.5 py-0.5 rounded-sm typography-micro text-[9px] transition-colors',
quotaDisplayMode === 'usage'
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => handleDisplayModeChange('usage')}
aria-label="Show used quota"
>
Used
</button>
<button
type="button"
className={cn(
'px-1.5 py-0.5 rounded-sm typography-micro text-[9px] transition-colors',
quotaDisplayMode === 'remaining'
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => handleDisplayModeChange('remaining')}
aria-label="Show remaining quota"
>
Remaining
</button>
</div>
<button
type="button"
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors',
'hover:text-foreground hover:bg-interactive-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
onClick={() => fetchAllQuotas()}
disabled={isQuotaLoading}
aria-label="Refresh rate limits"
>
<RiRefreshLine className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setIsMobileRateLimitsOpen(false)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover"
aria-label="Close rate limits"
>
<RiCloseLine className="h-5 w-5" />
</button>
</div>
</div>
<div className="px-3 pb-3 text-right typography-micro text-muted-foreground text-[8px]">
Last updated {formatTime(quotaLastUpdated)}
<AnimatedTabs<'usage' | 'mcp'>
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"
/>
<button
type="button"
onClick={() => setIsMobileRateLimitsOpen(false)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover"
aria-label="Close services"
>
<RiCloseLine className="h-5 w-5" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-[calc(4rem+env(safe-area-inset-bottom))]">
{!hasRateLimits && (
<div className="px-3 py-4 typography-ui-label text-muted-foreground">
No rate limits available.
</div>
)}
{rateLimitGroups.map((group) => (
<React.Fragment key={group.providerId}>
<div className="flex items-center gap-2 bg-[var(--surface-elevated)] px-3 py-2">
<ProviderLogo providerId={group.providerId} className="h-4 w-4" />
<span className="typography-ui-label text-foreground">{group.providerName}</span>
</div>
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
{group.error ?? 'No rate limits reported.'}
{mobileServicesTab === 'mcp' && (
<McpDropdownContent active={isMobileRateLimitsOpen && mobileServicesTab === 'mcp'} />
)}
{mobileServicesTab === 'usage' && (
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-[calc(4rem+env(safe-area-inset-bottom))]">
<div className="bg-[var(--surface-elevated)] border-b border-[var(--interactive-border)]">
<div className="flex items-center justify-between gap-3 px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
<span className="typography-ui-header font-semibold text-foreground">Rate limits</span>
<span className="truncate typography-ui-label text-muted-foreground">
Last updated {formatTime(quotaLastUpdated)}
</span>
</div>
)}
{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 (
<div key={`${group.providerId}-${label}`} className="px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="truncate typography-micro text-muted-foreground">
{formatWindowLabel(label)}
</span>
<span className="typography-ui-label text-foreground tabular-nums">
{formatPercent(displayPercent)}
</span>
</div>
<UsageProgressBar
percent={displayPercent}
tonePercent={window.usedPercent}
className="mt-2 h-1"
expectedMarkerPercent={expectedMarker}
/>
{paceInfo && (
<div className="mt-1.5">
<PaceIndicator paceInfo={paceInfo} compact />
<div className="flex items-center gap-1.5">
<AnimatedTabs<'usage' | 'remaining'>
value={quotaDisplayMode}
onValueChange={handleDisplayModeChange}
tabs={quotaDisplayTabs}
size="sm"
className="w-[10.5rem]"
/>
<button
type="button"
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
'hover:text-foreground hover:bg-interactive-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
onClick={handleUsageRefresh}
disabled={isQuotaLoading || isUsageRefreshSpinning}
aria-label="Refresh rate limits"
>
<RiRefreshLine className={cn('h-4 w-4', isUsageRefreshSpinning && 'animate-spin')} />
</button>
</div>
</div>
</div>
{!hasRateLimits && (
<DropdownMenuItem
className="cursor-default hover:bg-transparent focus:bg-transparent data-[highlighted]:bg-transparent"
onSelect={(event) => event.preventDefault()}
>
<span className="typography-ui-label text-muted-foreground">No rate limits available.</span>
</DropdownMenuItem>
)}
{rateLimitGroups.map((group) => (
<React.Fragment key={group.providerId}>
<DropdownMenuLabel className="flex items-center gap-2 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] typography-ui-label text-foreground">
<ProviderLogo providerId={group.providerId} className="h-4 w-4" />
{group.providerName}
</DropdownMenuLabel>
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
<DropdownMenuItem
key={`${group.providerId}-empty`}
className="cursor-default hover:bg-transparent focus:bg-transparent data-[highlighted]:bg-transparent"
onSelect={(event) => event.preventDefault()}
>
<span className="typography-ui-label text-muted-foreground">
{group.error ?? 'No rate limits reported.'}
</span>
</DropdownMenuItem>
) : (
<>
{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 (
<DropdownMenuItem
key={`${group.providerId}-${label}`}
className="cursor-default items-start hover:bg-transparent focus:bg-transparent data-[highlighted]:bg-transparent"
onSelect={(event) => event.preventDefault()}
>
<span className="flex min-w-0 flex-1 flex-col gap-2">
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="min-w-0 flex items-center gap-2">
<span className="truncate typography-ui-label text-foreground">{formatWindowLabel(label)}</span>
{(window.resetAfterFormatted ?? window.resetAtFormatted) ? (
<span className="truncate typography-ui-label text-muted-foreground">
{window.resetAfterFormatted ?? window.resetAtFormatted}
</span>
) : null}
</span>
<span className="typography-ui-label text-foreground tabular-nums">
{formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)}
</span>
</span>
<UsageProgressBar
percent={displayPercent}
tonePercent={window.usedPercent}
className="h-1.5"
expectedMarkerPercent={expectedMarker}
/>
{paceInfo && (
<div className="mb-1">
<PaceIndicator paceInfo={paceInfo} compact />
</div>
)}
</span>
</DropdownMenuItem>
);
})}
{group.modelFamilies && group.modelFamilies.length > 0 && (
<div className="px-2 py-1">
{group.modelFamilies.map((family) => {
const providerExpandedFamilies = expandedFamilies[group.providerId] ?? [];
const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other');
return (
<Collapsible
key={family.familyId ?? 'other'}
open={isExpanded}
onOpenChange={() => toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-1.5 text-left">
<span className="typography-ui-label font-medium text-foreground">
{family.familyLabel}
</span>
{isExpanded ? (
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
) : (
<RiArrowRightSLine className="h-4 w-4 text-muted-foreground" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-1 pl-2">
{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 (
<div
key={`${group.providerId}-${modelName}`}
className="py-1.5"
>
<div className="flex min-w-0 flex-col gap-1.5">
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate typography-micro text-muted-foreground">{getDisplayModelName(modelName)}</span>
<span className="typography-ui-label text-foreground tabular-nums">
{formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)}
</span>
</span>
<UsageProgressBar
percent={displayPercent}
tonePercent={window.usedPercent}
className="h-1.5"
expectedMarkerPercent={expectedMarker}
/>
{paceInfo && (
<div className="mb-1">
<PaceIndicator paceInfo={paceInfo} compact />
</div>
)}
</div>
</div>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
)}
<div className="mt-1 typography-micro text-muted-foreground text-[10px]">
{window.resetAfterFormatted ?? window.resetAtFormatted ?? ''}
</div>
</div>
);
})}
{/* Model families with collapsible sections */}
{group.modelFamilies && group.modelFamilies.length > 0 && (
<div className="px-2 py-1">
{group.modelFamilies.map((family) => {
const providerExpandedFamilies = expandedFamilies[group.providerId] ?? [];
const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other');
return (
<Collapsible
key={family.familyId ?? 'other'}
open={isExpanded}
onOpenChange={() => toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-left">
<span className="typography-ui-label font-medium text-foreground">
{family.familyLabel}
</span>
{isExpanded ? (
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
) : (
<RiArrowRightSLine className="h-4 w-4 text-muted-foreground" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-2 pl-2">
{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 (
<div key={`${group.providerId}-${modelName}`} className="py-1.5">
<div className="flex items-center justify-between gap-3">
<span className="truncate typography-micro text-muted-foreground">
{getDisplayModelName(modelName)}
</span>
<span className="typography-ui-label text-foreground tabular-nums">
{formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)}
</span>
</div>
<UsageProgressBar
percent={displayPercent}
tonePercent={window.usedPercent}
className="mt-1.5 h-1"
expectedMarkerPercent={expectedMarker}
/>
{paceInfo && (
<div className="mt-1">
<PaceIndicator paceInfo={paceInfo} compact />
</div>
)}
</div>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
)}
</React.Fragment>
))}
</div>
</>
)}
</React.Fragment>
))}
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleOpenSettings}
aria-label="Open settings"
className={cn(headerIconButtonClass, 'relative')}
>
<RiSettings3Line className="h-5 w-5" />
{updateAvailable && (
<span
className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-primary"
aria-label="Update available"
/>
)}
</button>
</TooltipTrigger>
<TooltipContent>
<p>{updateAvailable ? `Settings (Update available) (${shortcutLabel('open_settings')})` : `Settings (${shortcutLabel('open_settings')})`}</p>
</TooltipContent>
</Tooltip>
</div>
{onToggleRightDrawer ? (
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={onToggleRightDrawer}
className={cn(
headerIconButtonClass,
'relative',
rightDrawerOpen && 'bg-interactive-selection text-interactive-selection-foreground'
)}
aria-label={rightDrawerOpen ? 'Close git sidebar' : 'Open git sidebar'}
>
<RiLayoutRightLine className="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{rightDrawerOpen ? 'Close git sidebar' : 'Open git sidebar'}</p>
</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
</>
)}
</div>
);
+256 -16
View File
@@ -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 (
<div
className={className}
style={style}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{children}
</div>
);
};
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 = () => {
<SessionDialogs />
{isMobile ? (
<>
{/* Mobile: Header + content with drill-down pattern */}
{!(isSettingsDialogOpen || isMultiRunLauncherOpen) && <Header />}
<div
<DrawerProvider value={{
leftDrawerOpen: mobileLeftDrawerOpen,
rightDrawerOpen: isRightSidebarOpen,
toggleLeftDrawer: () => {
if (isRightSidebarOpen) {
setRightSidebarOpen(false);
}
setMobileLeftDrawerOpen(!mobileLeftDrawerOpen);
},
toggleRightDrawer: () => {
if (mobileLeftDrawerOpen) {
setMobileLeftDrawerOpen(false);
}
setRightSidebarOpen(!isRightSidebarOpen);
},
leftDrawerX,
rightDrawerX,
leftDrawerWidth,
rightDrawerWidth,
setMobileLeftDrawerOpen,
setRightSidebarOpen,
}}>
{/* Mobile: Header + Drawer 模式 */}
{!(isSettingsDialogOpen || isMultiRunLauncherOpen) && <Header
onToggleLeftDrawer={() => {
if (isRightSidebarOpen) {
setRightSidebarOpen(false);
}
setMobileLeftDrawerOpen(!mobileLeftDrawerOpen);
}}
onToggleRightDrawer={() => {
if (mobileLeftDrawerOpen) {
setMobileLeftDrawerOpen(false);
}
setRightSidebarOpen(!isRightSidebarOpen);
}}
leftDrawerOpen={mobileLeftDrawerOpen}
rightDrawerOpen={isRightSidebarOpen}
/>}
{/* 遮罩层 */}
<motion.button
type="button"
initial={false}
animate={{
opacity: mobileLeftDrawerOpen || isRightSidebarOpen ? 1 : 0,
pointerEvents: mobileLeftDrawerOpen || isRightSidebarOpen ? 'auto' : 'none',
}}
className="fixed inset-0 z-40 bg-black/50 cursor-default"
onClick={() => {
setMobileLeftDrawerOpen(false);
setRightSidebarOpen(false);
}}
aria-label="Close drawer"
/>
{/* 左抽屉(Session */}
<motion.aside
drag="x"
dragElastic={0.08}
dragMomentum={false}
dragConstraints={{ left: -(leftDrawerWidth.current || window.innerWidth * 0.85), right: 0 }}
style={{
width: `${MOBILE_DRAWER_WIDTH_PERCENT}%`,
x: leftDrawerX,
}}
onDragEnd={(_, info) => {
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}
>
<div className="h-full overflow-hidden flex flex-col bg-sidebar shadow-xl drawer-safe-area">
<div className="flex-1 overflow-hidden">
<ErrorBoundary>
<SessionSidebar mobileVariant />
</ErrorBoundary>
</div>
<div className="border-t border-border p-3">
<button
type="button"
onClick={() => {
setMobileLeftDrawerOpen(false);
setSettingsDialogOpen(true);
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-muted-foreground hover:bg-interactive-hover hover:text-foreground transition-colors"
>
<RiSettings3Line className="h-5 w-5" />
<span className="typography-ui-label">Settings</span>
</button>
</div>
</div>
</motion.aside>
{/* 右抽屉(Git */}
<motion.aside
drag="x"
dragElastic={0.08}
dragMomentum={false}
dragConstraints={{ left: 0, right: rightDrawerWidth.current || window.innerWidth * 0.85 }}
style={{
width: `${MOBILE_DRAWER_WIDTH_PERCENT}%`,
x: rightDrawerX,
}}
onDragEnd={(_, info) => {
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}
>
<div className="h-full overflow-hidden flex flex-col bg-background shadow-xl drawer-safe-area">
<ErrorBoundary>
<GitView mode="sidebar" />
</ErrorBoundary>
</div>
</motion.aside>
{/* 主内容区(固定) */}
<MobileDrawerGestureSurface
className={cn(
'flex flex-1 overflow-hidden relative',
(isSettingsDialogOpen || isMultiRunLauncherOpen) && 'hidden'
)}
style={{ paddingTop: 'var(--oc-header-height, 56px)' }}
>
{/* Mobile drill-down: show sessions sidebar OR main content */}
<div className={cn('flex-1 overflow-hidden bg-sidebar', !isSessionSwitcherOpen && 'hidden')}>
<ErrorBoundary><SessionSidebar mobileVariant /></ErrorBoundary>
</div>
<main className={cn('flex-1 overflow-hidden bg-background relative', isSessionSwitcherOpen && 'hidden')}>
<main className="w-full h-full overflow-hidden bg-background relative">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary>
</div>
@@ -526,7 +766,7 @@ export const MainLayout: React.FC = () => {
</div>
)}
</main>
</div>
</MobileDrawerGestureSurface>
{/* Mobile multi-run launcher: full screen */}
{isMultiRunLauncherOpen && (
@@ -547,7 +787,7 @@ export const MainLayout: React.FC = () => {
<ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary>
</div>
)}
</>
</DrawerProvider>
) : (
<>
{/* Desktop: Header always on top, then Sidebar + Content below */}
@@ -574,7 +814,7 @@ export const MainLayout: React.FC = () => {
</main>
<ContextPanel />
</div>
<RightSidebar isOpen={isRightSidebarOpen} isMobile={isMobile}>
<RightSidebar isOpen={isRightSidebarOpen}>
<ErrorBoundary><RightSidebarTabs /></ErrorBoundary>
</RightSidebar>
</div>
@@ -7,11 +7,10 @@ const RIGHT_SIDEBAR_MAX_WIDTH = 860;
interface RightSidebarProps {
isOpen: boolean;
isMobile: boolean;
children: React.ReactNode;
}
export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, isMobile, children }) => {
export const RightSidebar: React.FC<RightSidebarProps> = ({ 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<RightSidebarProps> = ({ 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<RightSidebarProps> = ({ 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))