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
@@ -2249,7 +2249,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
<div className="flex items-center min-w-0 gap-x-1 justify-end"> <div className="flex items-center min-w-0 gap-x-1 justify-end">
<div className="flex items-center gap-x-1 min-w-0 max-w-[60vw] flex-shrink"> <div className="flex items-center gap-x-1 min-w-0 max-w-[60vw] flex-shrink">
<MobileModelButton onOpenModel={handleOpenMobileControls} className="min-w-0 flex-shrink" /> <MobileModelButton onOpenModel={handleOpenMobileControls} className="min-w-0 flex-shrink" />
<MobileAgentButton onOpenAgentPanel={() => setMobileControlsPanel('agent')} className="min-w-0 flex-shrink" /> <MobileAgentButton
onOpenAgentPanel={() => setMobileControlsPanel('agent')}
onCycleAgent={handleCycleAgent}
className="min-w-0 flex-shrink"
/>
</div> </div>
<div className="flex items-center gap-x-1 flex-shrink-0"> <div className="flex items-center gap-x-1 flex-shrink-0">
<BrowserVoiceButton /> <BrowserVoiceButton />
@@ -6,32 +6,71 @@ import { getAgentDisplayName } from './mobileControlsUtils';
import { getAgentColor } from '@/lib/agentColors'; import { getAgentColor } from '@/lib/agentColors';
interface MobileAgentButtonProps { interface MobileAgentButtonProps {
onCycleAgent: () => void;
onOpenAgentPanel: () => void; onOpenAgentPanel: () => void;
className?: string; className?: string;
} }
export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onOpenAgentPanel, className }) => { const LONG_PRESS_MS = 500;
export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onOpenAgentPanel, onCycleAgent, className }) => {
const { currentAgentName, getVisibleAgents } = useConfigStore(); const { currentAgentName, getVisibleAgents } = useConfigStore();
const currentSessionId = useSessionStore((state) => state.currentSessionId); const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessionAgentName = useSessionStore((state) => const sessionAgentName = useSessionStore((state) =>
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
); );
const longPressTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressTriggeredRef = React.useRef(false);
const agents = getVisibleAgents(); const agents = getVisibleAgents();
const uiAgentName = currentSessionId ? (sessionAgentName || currentAgentName) : currentAgentName; const uiAgentName = currentSessionId ? (sessionAgentName || currentAgentName) : currentAgentName;
const agentLabel = getAgentDisplayName(agents, uiAgentName); const agentLabel = getAgentDisplayName(agents, uiAgentName);
const agentColor = getAgentColor(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 ( return (
<button <button
type="button" type="button"
onClick={onOpenAgentPanel} onPointerDown={(event) => {
if (event.button !== 0) return;
startLongPressTimer();
}}
onPointerUp={clearLongPressTimer}
onPointerLeave={clearLongPressTimer}
onPointerCancel={clearLongPressTimer}
onClick={(event) => {
if (longPressTriggeredRef.current) {
event.preventDefault();
longPressTriggeredRef.current = false;
return;
}
onCycleAgent();
}}
className={cn( className={cn(
'inline-flex min-w-0 items-center select-none', 'inline-flex min-w-0 items-center select-none',
'rounded-lg border border-border/50 px-1.5', 'rounded-lg border border-border/50 px-1.5',
'typography-micro font-medium', 'typography-micro font-medium',
'focus:outline-none hover:bg-[var(--interactive-hover)]', 'focus:outline-none hover:bg-[var(--interactive-hover)]',
'touch-manipulation active:scale-95 transition-transform', 'touch-none',
className className
)} )}
style={{ style={{
@@ -2,10 +2,28 @@ import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils'; import type { ProjectEntry } from '@/lib/api/types';
import { cn, formatDirectoryName } from '@/lib/utils';
import { getAgentColor } from '@/lib/agentColors'; import { getAgentColor } from '@/lib/agentColors';
import { RiLoader4Line } from '@remixicon/react'; import { RiLoader4Line, RiAddLine } from '@remixicon/react';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP } from '@/lib/projectMeta';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { toast } from '@/components/ui';
import { isTauriShell, isDesktopLocalOriginActive } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
interface MobileSessionStatusBarProps { interface MobileSessionStatusBarProps {
onSessionSwitch?: (sessionId: string) => void; onSessionSwitch?: (sessionId: string) => void;
@@ -19,6 +37,13 @@ interface SessionWithStatus extends Session {
_childIndicators?: Array<{ session: Session; isRunning: boolean }>; _childIndicators?: Array<{ session: Session; isRunning: boolean }>;
} }
// Normalize path for comparison
const normalize = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
};
function useSessionGrouping( function useSessionGrouping(
sessions: Session[], sessions: Session[],
sessionStatus: Map<string, { type: string }> | undefined, sessionStatus: Map<string, { type: string }> | undefined,
@@ -148,6 +173,88 @@ function useSessionHelpers(
return { getSessionAgentName, getSessionTitle, isRunning, needsAttention }; return { getSessionAgentName, getSessionTitle, isRunning, needsAttention };
} }
// Hook to calculate project status indicators
function useProjectStatus(
sessions: Session[],
sessionStatus: Map<string, { type: string }> | undefined,
sessionAttentionStates: Map<string, { needsAttention: boolean }> | undefined,
currentSessionId: string | null
) {
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory);
const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory);
const projectStatusMap = React.useMemo(() => {
const result = new Map<string, { hasRunning: boolean; hasUnread: boolean }>();
const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => {
const status = sessionStatus?.get(sessionId);
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
return 'idle';
};
return (projectPath: string): { hasRunning: boolean; hasUnread: boolean } => {
const cached = result.get(projectPath);
if (cached) return cached;
const projectRoot = normalize(projectPath);
if (!projectRoot) {
const empty = { hasRunning: false, hasUnread: false };
result.set(projectPath, empty);
return empty;
}
const dirs: string[] = [projectRoot];
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 && normalized !== projectRoot) {
dirs.push(normalized);
}
}
}
const seen = new Set<string>();
let hasRunning = false;
let hasUnread = false;
for (const dir of dirs) {
const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
for (const session of list) {
if (!session?.id || seen.has(session.id)) {
continue;
}
seen.add(session.id);
const statusType = getStatusType(session.id);
if (statusType === 'busy' || statusType === 'retry') {
hasRunning = true;
}
if (session.id !== currentSessionId && sessionAttentionStates?.get(session.id)?.needsAttention === true) {
hasUnread = true;
}
if (hasRunning && hasUnread) {
break;
}
}
if (hasRunning && hasUnread) {
break;
}
}
const status = { hasRunning, hasUnread };
result.set(projectPath, status);
return status;
};
}, [sessionsByDirectory, getSessionsByDirectory, availableWorktreesByProject, sessionStatus, sessionAttentionStates, currentSessionId]);
return projectStatusMap;
}
function StatusIndicator({ isRunning, needsAttention }: { isRunning: boolean; needsAttention: boolean }) { function StatusIndicator({ isRunning, needsAttention }: { isRunning: boolean; needsAttention: boolean }) {
if (isRunning) { if (isRunning) {
return <RiLoader4Line className="h-2.5 w-2.5 animate-spin text-[var(--status-info)]" />; return <RiLoader4Line className="h-2.5 w-2.5 animate-spin text-[var(--status-info)]" />;
@@ -161,9 +268,9 @@ function StatusIndicator({ isRunning, needsAttention }: { isRunning: boolean; ne
function RunningIndicator({ count }: { count: number }) { function RunningIndicator({ count }: { count: number }) {
if (count === 0) return null; if (count === 0) return null;
return ( return (
<span className="flex items-center gap-0.5 text-xs text-[var(--status-info)]"> <span className="flex items-center gap-1 text-[13px] text-[var(--status-info)]">
<RiLoader4Line className="h-3 w-3 animate-spin" /> <RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
{count} running {count}
</span> </span>
); );
} }
@@ -171,9 +278,9 @@ function RunningIndicator({ count }: { count: number }) {
function UnreadIndicator({ count }: { count: number }) { function UnreadIndicator({ count }: { count: number }) {
if (count === 0) return null; if (count === 0) return null;
return ( return (
<span className="flex items-center gap-0.5 text-xs text-[var(--status-error)]"> <span className="flex items-center gap-1 text-[13px] text-[var(--status-error)]">
<div className="h-1.5 w-1.5 rounded-full bg-[var(--status-error)]" /> <div className="h-2 w-2 rounded-full bg-[var(--status-error)]" />
{count} unread {count}
</span> </span>
); );
} }
@@ -264,81 +371,413 @@ function SessionItem({
); );
} }
function TokenUsageIndicator({ contextUsage }: { contextUsage: SessionContextUsage | null }) {
if (!contextUsage || contextUsage.totalTokens === 0) return null;
const percentage = Math.min(contextUsage.percentage, 999);
const colorClass =
percentage >= 90 ? 'text-[var(--status-error)]' :
percentage >= 75 ? 'text-[var(--status-warning)]' : 'text-[var(--status-success)]';
return (
<span className={cn("text-[12px] tabular-nums font-medium", colorClass)}>
{percentage.toFixed(1)}%
</span>
);
}
interface SessionStatusHeaderProps {
currentSessionTitle: string;
currentProjectLabel?: string;
currentProjectIcon?: string | null;
currentProjectColor?: string | null;
onToggle: () => void;
isExpanded?: boolean;
}
function SessionStatusHeader({ function SessionStatusHeader({
currentSessionTitle, currentSessionTitle,
runningCount, currentProjectLabel,
unreadCount, currentProjectIcon,
onToggle currentProjectColor,
}: { onToggle,
currentSessionTitle: string; isExpanded = false
runningCount: number; }: SessionStatusHeaderProps) {
unreadCount: number; const ProjectIcon = currentProjectIcon ? PROJECT_ICON_MAP[currentProjectIcon] : null;
onToggle: () => void; const projectColorVar = currentProjectColor ? (PROJECT_COLOR_MAP[currentProjectColor] ?? null) : null;
}) {
const hasActivity = runningCount > 0 || unreadCount > 0;
return ( return (
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
className="w-full flex items-center justify-between px-2 py-0 text-left transition-colors hover:bg-[var(--interactive-hover)]" className="w-full flex flex-col px-2 py-0.5 text-left transition-colors hover:bg-[var(--interactive-hover)]"
> >
<div className="flex items-center gap-1 flex-1 min-w-0 mr-2"> {!isExpanded && currentProjectLabel && (
<span className="text-xs text-[var(--surface-foreground)] truncate flex-1 leading-tight"> <div className="flex flex-col items-start">
{currentSessionTitle} <div className="flex items-center gap-1 leading-none">
</span> {ProjectIcon && (
{hasActivity && ( <ProjectIcon
<div className="flex items-center gap-1 flex-shrink-0"> className="h-2.5 w-2.5"
<RunningIndicator count={runningCount} /> style={projectColorVar ? { color: projectColorVar } : undefined}
<UnreadIndicator count={unreadCount} /> />
)}
<span
className="text-[11px] leading-none text-[var(--surface-mutedForeground)] truncate max-w-[120px]"
style={projectColorVar ? { color: projectColorVar } : undefined}
>
{currentProjectLabel}
</span>
</div> </div>
<div className="w-full h-px bg-[var(--interactive-border)] my-1" />
</div>
)}
<span className="text-[13px] text-[var(--surface-foreground)] truncate leading-none">
{currentSessionTitle}
</span>
</button>
);
}
// Hook for long press
function useLongPress(
onLongPress: () => void,
onClick: () => void,
ms = 500
) {
const timerRef = React.useRef<NodeJS.Timeout | null>(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 (
<button
type="button"
data-project-id={project.id}
className={cn(
"flex items-center gap-1 px-2.5 !py-1.5 rounded-md text-[12px] whitespace-nowrap transition-colors shrink-0 border !min-h-0 leading-none select-none",
isActive
? "border-[var(--primary-base)]/60 text-[var(--primary-base)]/80 bg-[var(--primary-base)]/5 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10"
: "border-[var(--interactive-border)] text-[var(--surface-foreground)] bg-[var(--surface-elevated)] hover:bg-[var(--interactive-hover)]"
)}
{...longPressHandlers}
>
{/* Status indicators */}
<div className="flex items-center gap-0.5">
{status.hasRunning && (
<RiLoader4Line className="h-2.5 w-2.5 animate-spin text-[var(--status-info)]" />
)}
{!status.hasRunning && status.hasUnread && (
<div className="h-1.5 w-1.5 rounded-full bg-[var(--status-error)]" />
)} )}
</div> </div>
{/* Icon */}
{ProjectIcon && (
<ProjectIcon
className="h-3.5 w-3.5"
style={projectColorVar ? { color: projectColorVar } : undefined}
/>
)}
{/* Label */}
<span
className="truncate max-w-[100px]"
style={isActive && projectColorVar ? { color: projectColorVar } : undefined}
>
{formatProjectLabel(project)}
</span>
</button> </button>
); );
} }
// 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<HTMLDivElement>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
const [projectToDelete, setProjectToDelete] = React.useState<ProjectEntry | null>(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 (
<div className="flex items-center gap-2 px-2 py-1 border-b border-[var(--interactive-border)] bg-transparent">
<span className="text-[11px] text-[var(--surface-mutedForeground)]">No projects</span>
<button
type="button"
onClick={onAddProject}
className="flex items-center justify-center !py-1.5 px-2 rounded-md border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 !min-h-0"
aria-label="Add project"
>
<RiAddLine className="h-3 w-3" />
</button>
</div>
);
}
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 (
<div className="flex items-center gap-1.5 px-2 py-1.5 bg-transparent">
<div
ref={scrollRef}
className="flex-1 flex items-center gap-1.5 overflow-x-auto scrollbar-none touch-pan-x"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{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 (
<ProjectButton
key={project.id}
project={project}
isActive={isActive}
status={status}
projectColorVar={projectColorVar}
onProjectSwitch={() => onProjectSwitch(project.id)}
onRemoveProject={onRemoveProject ? () => handleLongPress(project) : undefined}
formatProjectLabel={formatProjectLabel}
/>
);
})}
</div>
{/* Add project button */}
<button
type="button"
onClick={onAddProject}
className="flex items-center justify-center !py-1.5 px-2 rounded-md border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 shrink-0 !min-h-0"
aria-label="Add project"
>
<RiAddLine className="h-3.5 w-3.5" />
</button>
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Remove Project</DialogTitle>
<DialogDescription>
Are you sure you want to remove <span className="font-medium text-foreground">{projectToDelete?.label || formatDirectoryName(projectToDelete?.path || '', homeDirectory)}</span>?
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirmDelete}>
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function CollapsedView({ function CollapsedView({
runningCount, runningCount,
unreadCount, unreadCount,
currentSessionTitle, currentSessionTitle,
currentProjectLabel,
currentProjectIcon,
currentProjectColor,
onToggle, onToggle,
onNewSession, onNewSession,
cornerRadius, cornerRadius,
contextUsage,
}: { }: {
runningCount: number; runningCount: number;
unreadCount: number; unreadCount: number;
currentSessionTitle: string; currentSessionTitle: string;
currentProjectLabel?: string;
currentProjectIcon?: string | null;
currentProjectColor?: string | null;
onToggle: () => void; onToggle: () => void;
onNewSession: () => void; onNewSession: () => void;
cornerRadius?: number; cornerRadius?: number;
contextUsage: SessionContextUsage | null;
}) { }) {
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
return ( return (
<div <div
className="w-full flex items-center justify-between px-2 border-b border-[var(--interactive-border)] bg-[var(--surface-muted)] order-first text-left overflow-hidden" className="w-full flex items-center justify-between px-2 py-1 border-b border-[var(--interactive-border)] bg-[var(--surface-muted)] order-first text-left overflow-hidden"
style={{ style={{
borderTopLeftRadius: cornerRadius, borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius, borderTopRightRadius: cornerRadius,
}} }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
> >
<div className="flex-1 min-w-0 mr-2"> <div className="flex-1 min-w-0 mr-1">
<SessionStatusHeader <SessionStatusHeader
currentSessionTitle={currentSessionTitle} currentSessionTitle={currentSessionTitle}
runningCount={runningCount} currentProjectLabel={currentProjectLabel}
unreadCount={unreadCount} currentProjectIcon={currentProjectIcon}
currentProjectColor={currentProjectColor}
onToggle={onToggle} onToggle={onToggle}
/> />
</div> </div>
<button <div className="flex items-center gap-2 flex-shrink-0">
type="button" <button
onClick={(e) => { type="button"
e.stopPropagation(); className="flex items-center gap-2"
onNewSession(); onClick={onToggle}
}} >
className="flex items-center gap-0.5 px-1.5 py-1 text-[11px] leading-tight !min-h-0 rounded border border-border/50 text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] flex-shrink-0 self-center" <RunningIndicator count={runningCount} />
> <UnreadIndicator count={unreadCount} />
New <TokenUsageIndicator contextUsage={contextUsage} />
</button> </button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNewSession();
}}
className="flex items-center gap-0.5 px-2 py-1 text-[12px] leading-tight !min-h-0 rounded border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 self-center"
>
New
</button>
</div>
</div> </div>
); );
} }
@@ -349,36 +788,58 @@ function ExpandedView({
runningCount, runningCount,
unreadCount, unreadCount,
currentSessionTitle, currentSessionTitle,
currentProjectLabel,
currentProjectIcon,
currentProjectColor,
isExpanded, isExpanded,
onToggleCollapse, onToggleCollapse,
onToggleExpand,
onNewSession, onNewSession,
onSessionClick, onSessionClick,
onSessionDoubleClick, onSessionDoubleClick,
onProjectSwitch,
onAddProject,
onRemoveProject,
getSessionAgentName, getSessionAgentName,
getSessionTitle, getSessionTitle,
needsAttention, needsAttention,
cornerRadius, cornerRadius,
contextUsage,
projects,
activeProjectId,
getProjectStatus,
homeDirectory,
}: { }: {
sessions: SessionWithStatus[]; sessions: SessionWithStatus[];
currentSessionId: string; currentSessionId: string;
runningCount: number; runningCount: number;
unreadCount: number; unreadCount: number;
currentSessionTitle: string; currentSessionTitle: string;
currentProjectLabel?: string;
currentProjectIcon?: string | null;
currentProjectColor?: string | null;
isExpanded: boolean; isExpanded: boolean;
onToggleCollapse: () => void; onToggleCollapse: () => void;
onToggleExpand: () => void;
onNewSession: () => void; onNewSession: () => void;
onSessionClick: (id: string) => void; onSessionClick: (id: string) => void;
onSessionDoubleClick?: () => void; onSessionDoubleClick?: () => void;
onProjectSwitch: (projectId: string) => void;
onAddProject: () => void;
onRemoveProject?: (projectId: string) => void;
getSessionAgentName: (s: Session) => string; getSessionAgentName: (s: Session) => string;
getSessionTitle: (s: Session) => string; getSessionTitle: (s: Session) => string;
needsAttention: (sessionId: string) => boolean; needsAttention: (sessionId: string) => boolean;
cornerRadius?: number; cornerRadius?: number;
contextUsage: SessionContextUsage | null;
projects: ProjectEntry[];
activeProjectId: string | null;
getProjectStatus: (path: string) => { hasRunning: boolean; hasUnread: boolean };
homeDirectory: string | null;
}) { }) {
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const [collapsedHeight, setCollapsedHeight] = React.useState<number | null>(null); const [collapsedHeight, setCollapsedHeight] = React.useState<number | null>(null);
const [hasMeasured, setHasMeasured] = React.useState(false); const [hasMeasured, setHasMeasured] = React.useState(false);
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
React.useEffect(() => { React.useEffect(() => {
if (containerRef.current && !hasMeasured && !isExpanded) { if (containerRef.current && !hasMeasured && !isExpanded) {
@@ -387,8 +848,36 @@ function ExpandedView({
} }
}, [hasMeasured, isExpanded]); }, [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<string>([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 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 ( return (
<div <div
@@ -397,57 +886,74 @@ function ExpandedView({
borderTopLeftRadius: cornerRadius, borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius, borderTopRightRadius: cornerRadius,
}} }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
> >
<div className="flex items-center justify-between px-2 py-0"> {/* Header row */}
<div className="flex-1 min-w-0 mr-2"> <div className="flex items-center justify-between px-2 py-1.5 border-b border-[var(--interactive-border)]">
<div className="flex-1 min-w-0 mr-1">
<SessionStatusHeader <SessionStatusHeader
currentSessionTitle={currentSessionTitle} currentSessionTitle={currentSessionTitle}
runningCount={runningCount} currentProjectLabel={currentProjectLabel}
unreadCount={unreadCount} currentProjectIcon={currentProjectIcon}
currentProjectColor={currentProjectColor}
onToggle={onToggleCollapse} onToggle={onToggleCollapse}
isExpanded={true}
/> />
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<RunningIndicator count={runningCount} />
<UnreadIndicator count={unreadCount} />
<TokenUsageIndicator contextUsage={contextUsage} />
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onNewSession(); onNewSession();
}} }}
className="flex items-center gap-0.5 px-1.5 py-1 text-[11px] leading-tight !min-h-0 rounded border border-border/50 text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] self-start" className="flex items-center gap-0.5 px-2 py-1 text-[12px] leading-tight !min-h-0 rounded border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 self-start"
> >
New New
</button> </button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className="text-[11px] leading-tight px-1.5 py-1 !min-h-0 rounded border border-border/50 text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] self-start"
>
{isExpanded ? 'Less' : 'More'}
</button>
</div> </div>
</div> </div>
{/* Project switcher bar */}
<ProjectBar
projects={projects}
activeProjectId={activeProjectId}
getProjectStatus={getProjectStatus}
onProjectSwitch={onProjectSwitch}
onAddProject={onAddProject}
onRemoveProject={onRemoveProject}
homeDirectory={homeDirectory}
/>
{/* Sessions list */}
<div <div
ref={containerRef} ref={containerRef}
className="flex flex-col overflow-y-auto" className="flex flex-col overflow-y-auto"
style={{ maxHeight: isExpanded ? '60vh' : previewHeight }} style={{ maxHeight: isExpanded ? '60vh' : previewHeight }}
> >
{displaySessions.map((session) => ( {displaySessions.length === 0 ? (
<SessionItem <div className="flex items-center justify-center py-3 text-[11px] text-[var(--surface-mutedForeground)]">
key={session.id} <span>No sessions in this project</span>
session={session} </div>
isCurrent={session.id === currentSessionId} ) : (
getSessionAgentName={getSessionAgentName} displaySessions.map((session) => (
getSessionTitle={getSessionTitle} <SessionItem
onClick={() => onSessionClick(session.id)} key={session.id}
onDoubleClick={onSessionDoubleClick} session={session}
needsAttention={needsAttention} isCurrent={session.id === currentSessionId}
/> getSessionAgentName={getSessionAgentName}
))} getSessionTitle={getSessionTitle}
onClick={() => onSessionClick(session.id)}
onDoubleClick={onSessionDoubleClick}
needsAttention={needsAttention}
/>
))
)}
</div> </div>
</div> </div>
); );
@@ -462,19 +968,51 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
const sessionStatus = useSessionStore((state) => state.sessionStatus); const sessionStatus = useSessionStore((state) => state.sessionStatus);
const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates); const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates);
const setCurrentSession = useSessionStore((state) => state.setCurrentSession); 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 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 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 { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus, sessionAttentionStates);
const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, 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 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<string, unknown>)
: 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; return null;
} }
@@ -489,23 +1027,56 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
setActiveMainTab('chat'); setActiveMainTab('chat');
}; };
const handleCreateSession = async () => { const handleCreateSession = () => {
const newSession = await createSession(); openNewSessionDraft();
if (newSession) { };
setCurrentSession(newSession.id);
onSessionSwitch?.(newSession.id); 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) { if (isMobileSessionStatusBarCollapsed) {
return ( return (
<CollapsedView <CollapsedView
runningCount={totalRunning} runningCount={totalRunning}
unreadCount={totalUnread} unreadCount={totalUnread}
currentSessionTitle={currentSessionTitle} currentSessionTitle={currentSessionTitle}
currentProjectLabel={currentProjectLabel}
currentProjectIcon={currentProjectIcon}
currentProjectColor={currentProjectColor}
onToggle={() => setIsMobileSessionStatusBarCollapsed(false)} onToggle={() => setIsMobileSessionStatusBarCollapsed(false)}
onNewSession={handleCreateSession} onNewSession={handleCreateSession}
cornerRadius={cornerRadius} cornerRadius={cornerRadius}
contextUsage={contextUsage}
/> />
); );
} }
@@ -517,19 +1088,29 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
runningCount={totalRunning} runningCount={totalRunning}
unreadCount={totalUnread} unreadCount={totalUnread}
currentSessionTitle={currentSessionTitle} currentSessionTitle={currentSessionTitle}
currentProjectLabel={currentProjectLabel}
currentProjectIcon={currentProjectIcon}
currentProjectColor={currentProjectColor}
isExpanded={isExpanded} isExpanded={isExpanded}
onToggleCollapse={() => { onToggleCollapse={() => {
setIsMobileSessionStatusBarCollapsed(true); setIsMobileSessionStatusBarCollapsed(true);
setIsExpanded(false); setIsExpanded(false);
}} }}
onToggleExpand={() => setIsExpanded(!isExpanded)}
onNewSession={handleCreateSession} onNewSession={handleCreateSession}
onSessionClick={handleSessionClick} onSessionClick={handleSessionClick}
onSessionDoubleClick={handleSessionDoubleClick} onSessionDoubleClick={handleSessionDoubleClick}
onProjectSwitch={handleProjectSwitch}
onAddProject={handleAddProject}
onRemoveProject={removeProject}
getSessionAgentName={getSessionAgentName} getSessionAgentName={getSessionAgentName}
getSessionTitle={getSessionTitle} getSessionTitle={getSessionTitle}
needsAttention={needsAttention} needsAttention={needsAttention}
cornerRadius={cornerRadius} cornerRadius={cornerRadius}
contextUsage={contextUsage}
projects={projects}
activeProjectId={activeProjectId}
getProjectStatus={getProjectStatus}
homeDirectory={homeDirectory}
/> />
); );
}; };
@@ -148,11 +148,6 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isExpanded]); }, [isExpanded]);
// Don't render if nothing to show
if (!hasContent) {
return null;
}
const toggleExpanded = () => setIsExpanded((prev) => !prev); const toggleExpanded = () => setIsExpanded((prev) => !prev);
// Abort button for mobile/vscode // Abort button for mobile/vscode
@@ -193,9 +188,13 @@ export const StatusRow: React.FC<StatusRowProps> = ({
</button> </button>
) : null; ) : null;
// Don't render if nothing to show
if (!hasContent) {
return null;
}
return ( return (
<div className="chat-column mb-1" style={{ containerType: "inline-size" }}> <div className="chat-column mb-1" style={{ containerType: "inline-size" }}>
{/* Main status row */}
<div className="flex items-center justify-between pr-[2ch] py-0.5 gap-2 h-[1.2rem]"> <div className="flex items-center justify-between pr-[2ch] py-0.5 gap-2 h-[1.2rem]">
{/* Left: Abort status or Working placeholder */} {/* Left: Abort status or Working placeholder */}
<div className="flex-1 flex items-center overflow-hidden min-w-0"> <div className="flex-1 flex items-center overflow-hidden min-w-0">
+328 -296
View File
@@ -15,10 +15,9 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { AnimatedTabs } from '@/components/ui/animated-tabs'; 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 { DiffIcon } from '@/components/icons/DiffIcon';
import { useUIStore, type MainTab } from '@/stores/useUIStore'; import { useUIStore, type MainTab } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore'; import { useSessionStore } from '@/stores/useSessionStore';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
@@ -29,8 +28,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { cn, hasModifier, formatDirectoryName } from '@/lib/utils'; import { cn, hasModifier, formatDirectoryName } from '@/lib/utils';
import { useDiffFileCount } from '@/components/views/DiffView'; import { McpDropdownContent } from '@/components/mcp/McpDropdown';
import { McpDropdown, McpDropdownContent } from '@/components/mcp/McpDropdown';
import { McpIcon } from '@/components/icons/McpIcon'; import { McpIcon } from '@/components/icons/McpIcon';
import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota'; import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
@@ -138,7 +136,19 @@ interface TabConfig {
showDot?: boolean; 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 setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const toggleSidebar = useUIStore((state) => state.toggleSidebar); const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const toggleBottomTerminal = useUIStore((state) => state.toggleBottomTerminal); const toggleBottomTerminal = useUIStore((state) => state.toggleBottomTerminal);
@@ -147,7 +157,6 @@ export const Header: React.FC = () => {
const openContextPlan = useUIStore((state) => state.openContextPlan); const openContextPlan = useUIStore((state) => state.openContextPlan);
const closeContextPanel = useUIStore((state) => state.closeContextPanel); const closeContextPanel = useUIStore((state) => state.closeContextPanel);
const contextPanelByDirectory = useUIStore((state) => state.contextPanelByDirectory); const contextPanelByDirectory = useUIStore((state) => state.contextPanelByDirectory);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const activeMainTab = useUIStore((state) => state.activeMainTab); const activeMainTab = useUIStore((state) => state.activeMainTab);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
@@ -186,8 +195,6 @@ export const Header: React.FC = () => {
const removeProject = useProjectsStore((state) => state.removeProject); const removeProject = useProjectsStore((state) => state.removeProject);
const { isMobile } = useDeviceInfo(); const { isMobile } = useDeviceInfo();
const diffFileCount = useDiffFileCount();
const updateAvailable = useUpdateStore((state) => state.available);
const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const setGitHubAuthStatus = useGitHubAuthStore((state) => state.setStatus); const setGitHubAuthStatus = useGitHubAuthStore((state) => state.setStatus);
@@ -278,6 +285,7 @@ export const Header: React.FC = () => {
const [desktopServicesTab, setDesktopServicesTab] = React.useState<'instance' | 'usage' | 'mcp'>( const [desktopServicesTab, setDesktopServicesTab] = React.useState<'instance' | 'usage' | 'mcp'>(
isDesktopApp ? 'instance' : 'usage' isDesktopApp ? 'instance' : 'usage'
); );
const [mobileServicesTab, setMobileServicesTab] = React.useState<'usage' | 'mcp'>('usage');
useEffect(() => { useEffect(() => {
if (!isDesktopApp && desktopServicesTab === 'instance') { if (!isDesktopApp && desktopServicesTab === 'instance') {
setDesktopServicesTab('usage'); setDesktopServicesTab('usage');
@@ -865,14 +873,6 @@ export const Header: React.FC = () => {
toggleSidebar(); toggleSidebar();
}, [blurActiveElement, isMobile, isSessionSwitcherOpen, setSessionSwitcherOpen, 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 handleOpenContextPanel = React.useCallback(() => {
const directory = normalize(openDirectory || ''); const directory = normalize(openDirectory || '');
if (!directory) { if (!directory) {
@@ -1030,17 +1030,11 @@ export const Header: React.FC = () => {
label: 'Terminal', label: 'Terminal',
icon: RiTerminalBoxLine, icon: RiTerminalBoxLine,
}, },
{
id: 'git',
label: 'Git',
icon: RiGitBranchLine,
showDot: diffFileCount > 0,
},
); );
} }
return base; return base;
}, [diffFileCount, isMobile, showPlanTab]); }, [isMobile, showPlanTab]);
const shortcutLabel = React.useCallback((actionId: string) => { const shortcutLabel = React.useCallback((actionId: string) => {
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)); return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
@@ -2126,11 +2120,24 @@ export const Header: React.FC = () => {
); );
const renderMobile = () => ( 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"> <div className="flex items-center gap-2 shrink-0">
{/* Show back button when sessions sidebar is open, otherwise show sessions toggle */} {/* Use drawer toggle when onToggleLeftDrawer is provided, otherwise use legacy session switcher */}
{isSessionSwitcherOpen ? ( {onToggleLeftDrawer ? (
<button <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)} 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" 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" aria-label="Back"
@@ -2139,6 +2146,7 @@ export const Header: React.FC = () => {
</button> </button>
) : ( ) : (
<button <button
type="button"
onClick={handleOpenSessionSwitcher} 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" 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" aria-label="Open sessions"
@@ -2146,16 +2154,7 @@ export const Header: React.FC = () => {
<RiPlayListAddLine className="h-5 w-5" /> <RiPlayListAddLine className="h-5 w-5" />
</button> </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 && ( {isSessionSwitcherOpen && (
<span className="typography-ui-label font-semibold text-foreground">Sessions</span> <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 */} {/* Hide tabs and right-side buttons when sessions sidebar is open */}
{!isSessionSwitcherOpen && ( {!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="app-region-no-drag flex min-w-0 flex-1 items-center">
<div className="flex w-max items-center gap-1 pr-1"> <div className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden scrollbar-hidden touch-pan-x overscroll-x-contain">
<div <div className="flex w-max items-center gap-1 pr-1">
className="flex items-center gap-0.5 rounded-lg bg-[var(--surface-muted)]/50 p-0.5" <div
role="tablist" className="flex items-center gap-0.5 rounded-lg bg-[var(--surface-muted)]/50 p-0.5"
aria-label="Main navigation" role="tablist"
> aria-label="Main navigation"
{tabs.map((tab) => { >
const isActive = activeMainTab === tab.id; {tabs.map((tab) => {
const isDiffTab = tab.icon === 'diff'; const isActive = activeMainTab === tab.id;
const Icon = isDiffTab ? null : (tab.icon as RemixiconComponentType); const isDiffTab = tab.icon === 'diff';
return ( const Icon = isDiffTab ? null : (tab.icon as RemixiconComponentType);
<Tooltip key={tab.id} delayDuration={500}> return (
<TooltipTrigger asChild> <Tooltip key={tab.id} delayDuration={500}>
<button <TooltipTrigger asChild>
type="button" <button
onClick={() => { type="button"
if (isMobile) { onClick={() => {
blurActiveElement(); if (isMobile) {
} blurActiveElement();
setActiveMainTab(tab.id); }
}} setActiveMainTab(tab.id);
aria-label={tab.label} }}
aria-selected={isActive} aria-label={tab.label}
role="tab" aria-selected={isActive}
className={cn( role="tab"
headerIconButtonClass, className={cn(
'relative rounded-lg', headerIconButtonClass,
isActive && 'bg-interactive-selection text-interactive-selection-foreground' 'relative rounded-lg',
)} isActive && 'bg-interactive-selection text-interactive-selection-foreground'
> )}
{isDiffTab ? ( >
<DiffIcon className="h-5 w-5" /> {isDiffTab ? (
) : Icon ? ( <DiffIcon className="h-5 w-5" />
<Icon className="h-5 w-5" /> ) : Icon ? (
) : null} <Icon className="h-5 w-5" />
{tab.badge !== undefined && tab.badge > 0 && ( ) : null}
<span className="absolute -top-1 -right-1 text-[10px] font-semibold text-primary"> {tab.badge !== undefined && tab.badge > 0 && (
{tab.badge} <span className="absolute -top-1 -right-1 text-[10px] font-semibold text-primary">
</span> {tab.badge}
)} </span>
{tab.showDot && ( )}
<span {tab.showDot && (
className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-primary" <span
aria-label="Changes available" className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-primary"
/> aria-label="Changes available"
)} />
</button> )}
</TooltipTrigger> </button>
<TooltipContent> </TooltipTrigger>
<p>{tab.label}</p> <TooltipContent>
</TooltipContent> <p>{tab.label}</p>
</Tooltip> </TooltipContent>
); </Tooltip>
})} );
})}
</div>
</div>
</div> </div>
</div>
<McpDropdown headerIconButtonClass={headerIconButtonClass} /> <div className="flex items-center gap-1 shrink-0">
{/* Mobile Services Menu (Usage + MCP) */}
<DropdownMenu <DropdownMenu
open={isMobileRateLimitsOpen} open={isMobileRateLimitsOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -2237,16 +2241,15 @@ export const Header: React.FC = () => {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
aria-label="View rate limits" aria-label="View services"
className={headerIconButtonClass} className={headerIconButtonClass}
disabled={isQuotaLoading}
> >
<RiTimerLine className="h-5 w-5" /> <RiStackLine className="h-5 w-5" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Rate limits</p> <p>Services</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<DropdownMenuContent <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" 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="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"> <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> <AnimatedTabs<'usage' | 'mcp'>
<div className="flex items-center gap-2"> value={mobileServicesTab}
<div className="flex items-center rounded-md border border-[var(--interactive-border)] p-0.5"> onValueChange={(value) => {
<button setMobileServicesTab(value);
type="button" if (value === 'usage' && quotaResults.length === 0) {
className={cn( fetchAllQuotas();
'px-1.5 py-0.5 rounded-sm typography-micro text-[9px] transition-colors', }
quotaDisplayMode === 'usage' }}
? 'bg-interactive-selection text-interactive-selection-foreground' tabs={[
: 'text-muted-foreground hover:text-foreground' { value: 'usage', label: 'Usage', icon: RiTimerLine },
)} { value: 'mcp', label: 'MCP', icon: RiCommandLine },
onClick={() => handleDisplayModeChange('usage')} ]}
aria-label="Show used quota" className="rounded-md"
> />
Used <button
</button> type="button"
<button onClick={() => setIsMobileRateLimitsOpen(false)}
type="button" className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover"
className={cn( aria-label="Close services"
'px-1.5 py-0.5 rounded-sm typography-micro text-[9px] transition-colors', >
quotaDisplayMode === 'remaining' <RiCloseLine className="h-5 w-5" />
? 'bg-interactive-selection text-interactive-selection-foreground' </button>
: '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)}
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-[calc(4rem+env(safe-area-inset-bottom))]">
{!hasRateLimits && ( {mobileServicesTab === 'mcp' && (
<div className="px-3 py-4 typography-ui-label text-muted-foreground"> <McpDropdownContent active={isMobileRateLimitsOpen && mobileServicesTab === 'mcp'} />
No rate limits available. )}
</div>
)} {mobileServicesTab === 'usage' && (
{rateLimitGroups.map((group) => ( <div className="flex-1 overflow-y-auto overflow-x-hidden pb-[calc(4rem+env(safe-area-inset-bottom))]">
<React.Fragment key={group.providerId}> <div className="bg-[var(--surface-elevated)] border-b border-[var(--interactive-border)]">
<div className="flex items-center gap-2 bg-[var(--surface-elevated)] px-3 py-2"> <div className="flex items-center justify-between gap-3 px-3 py-2.5">
<ProviderLogo providerId={group.providerId} className="h-4 w-4" /> <div className="flex min-w-0 items-center gap-2">
<span className="typography-ui-label text-foreground">{group.providerName}</span> <span className="typography-ui-header font-semibold text-foreground">Rate limits</span>
</div> <span className="truncate typography-ui-label text-muted-foreground">
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) && ( Last updated {formatTime(quotaLastUpdated)}
<div className="px-3 py-2 typography-ui-label text-muted-foreground"> </span>
{group.error ?? 'No rate limits reported.'}
</div> </div>
)} <div className="flex items-center gap-1.5">
{group.entries.map(([label, window]) => { <AnimatedTabs<'usage' | 'remaining'>
const displayPercent = quotaDisplayMode === 'remaining' value={quotaDisplayMode}
? window.remainingPercent onValueChange={handleDisplayModeChange}
: window.usedPercent; tabs={quotaDisplayTabs}
const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label); size="sm"
const expectedMarker = paceInfo?.dailyAllocationPercent != null className="w-[10.5rem]"
? (quotaDisplayMode === 'remaining' />
? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) <button
: calculateExpectedUsagePercent(paceInfo.elapsedRatio)) type="button"
: null; className={cn(
return ( 'inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
<div key={`${group.providerId}-${label}`} className="px-3 py-2"> 'hover:text-foreground hover:bg-interactive-hover',
<div className="flex items-center justify-between gap-3"> 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
<span className="truncate typography-micro text-muted-foreground"> )}
{formatWindowLabel(label)} onClick={handleUsageRefresh}
</span> disabled={isQuotaLoading || isUsageRefreshSpinning}
<span className="typography-ui-label text-foreground tabular-nums"> aria-label="Refresh rate limits"
{formatPercent(displayPercent)} >
</span> <RiRefreshLine className={cn('h-4 w-4', isUsageRefreshSpinning && 'animate-spin')} />
</div> </button>
<UsageProgressBar </div>
percent={displayPercent} </div>
tonePercent={window.usedPercent} </div>
className="mt-2 h-1" {!hasRateLimits && (
expectedMarkerPercent={expectedMarker} <DropdownMenuItem
/> className="cursor-default hover:bg-transparent focus:bg-transparent data-[highlighted]:bg-transparent"
{paceInfo && ( onSelect={(event) => event.preventDefault()}
<div className="mt-1.5"> >
<PaceIndicator paceInfo={paceInfo} compact /> <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>
)} )}
<div className="mt-1 typography-micro text-muted-foreground text-[10px]"> </>
{window.resetAfterFormatted ?? window.resetAtFormatted ?? ''} )}
</div> </React.Fragment>
</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>
</div> </div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Tooltip delayDuration={500}> {onToggleRightDrawer ? (
<TooltipTrigger asChild> <Tooltip delayDuration={500}>
<button <TooltipTrigger asChild>
type="button" <button
onClick={handleOpenSettings} type="button"
aria-label="Open settings" onClick={onToggleRightDrawer}
className={cn(headerIconButtonClass, 'relative')} className={cn(
> headerIconButtonClass,
<RiSettings3Line className="h-5 w-5" /> 'relative',
{updateAvailable && ( rightDrawerOpen && 'bg-interactive-selection text-interactive-selection-foreground'
<span )}
className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-primary" aria-label={rightDrawerOpen ? 'Close git sidebar' : 'Open git sidebar'}
aria-label="Update available" >
/> <RiLayoutRightLine className="h-5 w-5" />
)} </button>
</button> </TooltipTrigger>
</TooltipTrigger> <TooltipContent>
<TooltipContent> <p>{rightDrawerOpen ? 'Close git sidebar' : 'Open git sidebar'}</p>
<p>{updateAvailable ? `Settings (Update available) (${shortcutLabel('open_settings')})` : `Settings (${shortcutLabel('open_settings')})`}</p> </TooltipContent>
</TooltipContent> </Tooltip>
</Tooltip> ) : null}
</div>
</div> </div>
</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 { Header } from './Header';
import { BottomTerminalDock } from './BottomTerminalDock'; import { BottomTerminalDock } from './BottomTerminalDock';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
@@ -13,16 +15,20 @@ import { SessionSidebar } from '@/components/session/SessionSidebar';
import { SessionDialogs } from '@/components/session/SessionDialogs'; import { SessionDialogs } from '@/components/session/SessionDialogs';
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider'; import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
import { MultiRunLauncher } from '@/components/multirun'; import { MultiRunLauncher } from '@/components/multirun';
import { DrawerProvider } from '@/contexts/DrawerContext';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore'; import { useUpdateStore } from '@/stores/useUpdateStore';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useEdgeSwipe } from '@/hooks/useEdgeSwipe'; import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow } from '@/components/views'; import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow } from '@/components/views';
// 移动端抽屉宽度(占屏幕比例)
const MOBILE_DRAWER_WIDTH_PERCENT = 85;
const normalizeDirectoryKey = (value: string): string => { const normalizeDirectoryKey = (value: string): string => {
if (!value) return ''; if (!value) return '';
@@ -42,6 +48,26 @@ const normalizeDirectoryKey = (value: string): string => {
return normalized; 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 = () => { export const MainLayout: React.FC = () => {
const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140; const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140;
const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220; const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220;
@@ -78,7 +104,64 @@ export const MainLayout: React.FC = () => {
const bottomTerminalAutoClosedRef = React.useRef(false); const bottomTerminalAutoClosedRef = React.useRef(false);
const leftSidebarAutoClosedByContextRef = 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) // Trigger update check 3 seconds after mount (for both mobile and desktop)
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates); const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
@@ -502,21 +585,178 @@ export const MainLayout: React.FC = () => {
<SessionDialogs /> <SessionDialogs />
{isMobile ? ( {isMobile ? (
<> <DrawerProvider value={{
{/* Mobile: Header + content with drill-down pattern */} leftDrawerOpen: mobileLeftDrawerOpen,
{!(isSettingsDialogOpen || isMultiRunLauncherOpen) && <Header />} rightDrawerOpen: isRightSidebarOpen,
<div 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( 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' (isSettingsDialogOpen || isMultiRunLauncherOpen) && 'hidden'
)} )}
style={{ paddingTop: 'var(--oc-header-height, 56px)' }} style={{ paddingTop: 'var(--oc-header-height, 56px)' }}
> >
{/* Mobile drill-down: show sessions sidebar OR main content */} <main className="w-full h-full overflow-hidden bg-background relative">
<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')}>
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}> <div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary> <ErrorBoundary><ChatView /></ErrorBoundary>
</div> </div>
@@ -526,7 +766,7 @@ export const MainLayout: React.FC = () => {
</div> </div>
)} )}
</main> </main>
</div> </MobileDrawerGestureSurface>
{/* Mobile multi-run launcher: full screen */} {/* Mobile multi-run launcher: full screen */}
{isMultiRunLauncherOpen && ( {isMultiRunLauncherOpen && (
@@ -547,7 +787,7 @@ export const MainLayout: React.FC = () => {
<ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary> <ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary>
</div> </div>
)} )}
</> </DrawerProvider>
) : ( ) : (
<> <>
{/* Desktop: Header always on top, then Sidebar + Content below */} {/* Desktop: Header always on top, then Sidebar + Content below */}
@@ -574,7 +814,7 @@ export const MainLayout: React.FC = () => {
</main> </main>
<ContextPanel /> <ContextPanel />
</div> </div>
<RightSidebar isOpen={isRightSidebarOpen} isMobile={isMobile}> <RightSidebar isOpen={isRightSidebarOpen}>
<ErrorBoundary><RightSidebarTabs /></ErrorBoundary> <ErrorBoundary><RightSidebarTabs /></ErrorBoundary>
</RightSidebar> </RightSidebar>
</div> </div>
@@ -7,11 +7,10 @@ const RIGHT_SIDEBAR_MAX_WIDTH = 860;
interface RightSidebarProps { interface RightSidebarProps {
isOpen: boolean; isOpen: boolean;
isMobile: boolean;
children: React.ReactNode; 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 rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
const setRightSidebarWidth = useUIStore((state) => state.setRightSidebarWidth); const setRightSidebarWidth = useUIStore((state) => state.setRightSidebarWidth);
const [isResizing, setIsResizing] = React.useState(false); 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); const startWidthRef = React.useRef(rightSidebarWidth || 420);
React.useEffect(() => { React.useEffect(() => {
if (isMobile || !isResizing) { if (!isResizing) {
return; return;
} }
@@ -43,17 +42,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, isMobile, ch
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointerup', handlePointerUp);
}; };
}, [isMobile, isResizing, setRightSidebarWidth]); }, [isResizing, setRightSidebarWidth]);
React.useEffect(() => {
if (isMobile && isResizing) {
setIsResizing(false);
}
}, [isMobile, isResizing]);
if (isMobile) {
return null;
}
const appliedWidth = isOpen const appliedWidth = isOpen
? Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || 420)) ? Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || 420))
@@ -102,9 +102,9 @@ const VisualSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'terminalFontSize', 'spacing', 'cornerRadius', 'inputBarOffset', 'terminalQuickKeys']} />; return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'terminalFontSize', 'spacing', 'cornerRadius', 'inputBarOffset', 'terminalQuickKeys']} />;
}; };
// 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 = () => { const ChatSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'diffLayout', 'dotfiles', 'reasoning', 'textJustificationActivity', 'queueMode', 'persistDraft']} />; return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'reasoning', 'textJustificationActivity', 'queueMode', 'persistDraft']} />;
}; };
// Sessions section: Default model & agent, Session retention, Memory limits // Sessions section: Default model & agent, Session retention, Memory limits
@@ -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 { interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */ /** Which settings to show. If undefined, shows all. */
@@ -119,6 +119,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setQueueMode = useMessageQueueStore(state => state.setQueueMode); const setQueueMode = useMessageQueueStore(state => state.setQueueMode);
const persistChatDraft = useUIStore(state => state.persistChatDraft); const persistChatDraft = useUIStore(state => state.persistChatDraft);
const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft); const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft);
const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar);
const setShowMobileSessionStatusBar = useUIStore(state => state.setShowMobileSessionStatusBar);
const { const {
themeMode, themeMode,
setThemeMode, setThemeMode,
@@ -171,6 +173,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset'); const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset');
const hasBehaviorSettings = shouldShow('toolOutput') const hasBehaviorSettings = shouldShow('toolOutput')
|| shouldShow('diffLayout') || shouldShow('diffLayout')
|| shouldShow('mobileStatusBar')
|| shouldShow('dotfiles') || shouldShow('dotfiles')
|| shouldShow('reasoning') || shouldShow('reasoning')
|| shouldShow('queueMode') || shouldShow('queueMode')
@@ -545,8 +548,31 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</section> </section>
)} )}
{(shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('reasoning') || shouldShow('textJustificationActivity')) && ( {(shouldShow('mobileStatusBar') || shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('reasoning') || shouldShow('textJustificationActivity')) && (
<section className="p-2 space-y-0.5"> <section className="p-2 space-y-0.5">
{shouldShow('mobileStatusBar') && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={showMobileSessionStatusBar}
onClick={() => setShowMobileSessionStatusBar(!showMobileSessionStatusBar)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setShowMobileSessionStatusBar(!showMobileSessionStatusBar);
}
}}
>
<Checkbox
checked={showMobileSessionStatusBar}
onChange={setShowMobileSessionStatusBar}
ariaLabel="Show mobile status bar"
/>
<span className="typography-ui-label text-foreground">Show Mobile Status Bar</span>
</div>
)}
{shouldShow('dotfiles') && !isVSCodeRuntime() && ( {shouldShow('dotfiles') && !isVSCodeRuntime() && (
<div <div
className="group flex cursor-pointer items-center gap-2 py-1.5" className="group flex cursor-pointer items-center gap-2 py-1.5"
+10 -3
View File
@@ -246,6 +246,10 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
fetchIdentity, fetchIdentity,
setLogMaxCount, setLogMaxCount,
} = useGitStore(); } = 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(() => { const initialSnapshot = React.useMemo(() => {
if (!currentDirectory) return null; if (!currentDirectory) return null;
@@ -1713,11 +1717,14 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
onSelectAll={selectAll} onSelectAll={selectAll}
onClearSelection={clearSelection} onClearSelection={clearSelection}
onViewDiff={(path) => { onViewDiff={(path) => {
if (isSidebarMode && currentDirectory) { if (isSidebarMode && currentDirectory && !isMobile) {
useUIStore.getState().openContextDiff(currentDirectory, path); openContextDiff(currentDirectory, path);
return; return;
} }
useUIStore.getState().navigateToDiff(path); navigateToDiff(path);
if (isSidebarMode && isMobile) {
setRightSidebarOpen(false);
}
}} }}
onRevertFile={handleRevertFile} onRevertFile={handleRevertFile}
/> />
@@ -335,6 +335,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const openPage = React.useCallback((slug: SettingsPageSlug) => { const openPage = React.useCallback((slug: SettingsPageSlug) => {
setSettingsPage(slug); setSettingsPage(slug);
autoNavSlugRef.current = slug;
if (!isMobile) { if (!isMobile) {
return; return;
} }
@@ -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<number>;
rightDrawerX: MotionValue<number>;
leftDrawerWidth: React.MutableRefObject<number>;
rightDrawerWidth: React.MutableRefObject<number>;
setMobileLeftDrawerOpen: (open: boolean) => void;
setRightSidebarOpen: (open: boolean) => void;
}
const DrawerContext = React.createContext<DrawerContextValue | null>(null);
export const DrawerProvider: React.FC<{
children: React.ReactNode;
value: DrawerContextValue;
}> = ({ children, value }) => {
return (
<DrawerContext.Provider value={value}>
{children}
</DrawerContext.Provider>
);
};
// 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;
};
+156
View File
@@ -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<boolean | null>(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,
};
}
+20
View File
@@ -220,7 +220,9 @@ interface UIStore {
showTerminalQuickKeysOnDesktop: boolean; showTerminalQuickKeysOnDesktop: boolean;
persistChatDraft: boolean; persistChatDraft: boolean;
showMobileSessionStatusBar: boolean;
isMobileSessionStatusBarCollapsed: boolean; isMobileSessionStatusBarCollapsed: boolean;
viewPagerPage: 'left' | 'center' | 'right';
isExpandedInput: boolean; isExpandedInput: boolean;
@@ -313,7 +315,9 @@ interface UIStore {
setSummaryLength: (value: number) => void; setSummaryLength: (value: number) => void;
setMaxLastMessageLength: (value: number) => void; setMaxLastMessageLength: (value: number) => void;
setPersistChatDraft: (value: boolean) => void; setPersistChatDraft: (value: boolean) => void;
setShowMobileSessionStatusBar: (value: boolean) => void;
setIsMobileSessionStatusBarCollapsed: (value: boolean) => void; setIsMobileSessionStatusBarCollapsed: (value: boolean) => void;
setViewPagerPage: (page: 'left' | 'center' | 'right') => void;
toggleExpandedInput: () => void; toggleExpandedInput: () => void;
setExpandedInput: (value: boolean) => void; setExpandedInput: (value: boolean) => void;
openMultiRunLauncher: () => void; openMultiRunLauncher: () => void;
@@ -412,6 +416,7 @@ export const useUIStore = create<UIStore>()(
showTerminalQuickKeysOnDesktop: false, showTerminalQuickKeysOnDesktop: false,
persistChatDraft: true, persistChatDraft: true,
showMobileSessionStatusBar: true,
isMobileSessionStatusBarCollapsed: false, isMobileSessionStatusBarCollapsed: false,
isExpandedInput: false, isExpandedInput: false,
shortcutOverrides: {}, shortcutOverrides: {},
@@ -1194,9 +1199,23 @@ export const useUIStore = create<UIStore>()(
setPersistChatDraft: (value) => { setPersistChatDraft: (value) => {
set({ persistChatDraft: value }); set({ persistChatDraft: value });
}, },
setShowMobileSessionStatusBar: (value) => {
set({ showMobileSessionStatusBar: value });
},
setIsMobileSessionStatusBarCollapsed: (value) => { setIsMobileSessionStatusBarCollapsed: (value) => {
set({ isMobileSessionStatusBarCollapsed: 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) => { setShortcutOverride: (actionId, combo) => {
set((state) => ({ set((state) => ({
@@ -1347,6 +1366,7 @@ export const useUIStore = create<UIStore>()(
summaryLength: state.summaryLength, summaryLength: state.summaryLength,
maxLastMessageLength: state.maxLastMessageLength, maxLastMessageLength: state.maxLastMessageLength,
persistChatDraft: state.persistChatDraft, persistChatDraft: state.persistChatDraft,
showMobileSessionStatusBar: state.showMobileSessionStatusBar,
isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed, isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed,
shortcutOverrides: state.shortcutOverrides, shortcutOverrides: state.shortcutOverrides,
}) })
+6
View File
@@ -273,6 +273,12 @@
padding-bottom: var(--oc-safe-area-bottom-visual) !important; 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 home indicator safe area - used when keyboard is open */
.ios-keyboard-safe-area { .ios-keyboard-safe-area {
padding-bottom: calc(var(--oc-keyboard-home-indicator, 34px) + var(--oc-safe-area-bottom-visual, 0px)) !important; padding-bottom: calc(var(--oc-keyboard-home-indicator, 34px) + var(--oc-safe-area-bottom-visual, 0px)) !important;