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:
committed by
GitHub
co-authored by
Jovines
Bohdan Triapitsyn
parent
7a11867a19
commit
25d616f009
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user