feat(mobile): refactor drawer system and session status bar (#494)

* feat(mobile): refactor drawer system with swipe gestures and improved status bar

- Add DrawerContext for centralized drawer state management
- Implement swipe gesture support for left/right drawers
- Refactor MobileSessionStatusBar with token usage indicator
- Update Header component with drawer toggle props
- Improve RightSidebar touch handling
- Add mobile-specific styling improvements
- Update UI store for drawer state management

* feat(mobile): update empty session hint text to clarify swipe gesture area

* fix(mobile): restore MobileAgentButton tap-to-cycle and long-press behavior

- Revert removal of tap-to-cycle agent switching functionality
- Re-add onCycleAgent prop and long-press detection (500ms)
- Click now cycles through primary agents, long-press opens selector panel
- Fixes regression from commit 569cc411

* feat(mobile): add project switcher bar and fix button interactions

- Add ProjectBar component to MobileSessionStatusBar for quick project switching
- Add useProjectStatus hook to track project-level session indicators
- Integrate project store with directory store for project management
- Add project status indicators (running/unread) to session status bar
- Fix MobileAgentButton pointer event handling with preventDefault
- Support horizontal scroll in project bar with touch gesture isolation

* feat(mobile): add long-press to remove projects and filter sessions by project

* fix: improve mobile agent switching UX

* fix: move drawer swipe hook to dedicated file

* refactor(mobile): simplify session status bar, remove More/Less toggle button

* fix: git diff navigation on mobile in sidebar mode

* feat(mobile): add configurable status bar and refine mobile chat controls

---------

Co-authored-by: Jovines <jovines@qq.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Jovines
2026-02-24 11:44:43 +02:00
committed by GitHub
co-authored by Jovines Bohdan Triapitsyn
parent 7a11867a19
commit 25d616f009
15 changed files with 1564 additions and 426 deletions
@@ -2249,7 +2249,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
<div className="flex items-center min-w-0 gap-x-1 justify-end">
<div className="flex items-center gap-x-1 min-w-0 max-w-[60vw] 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 className="flex items-center gap-x-1 flex-shrink-0">
<BrowserVoiceButton />
@@ -6,32 +6,71 @@ import { getAgentDisplayName } from './mobileControlsUtils';
import { getAgentColor } from '@/lib/agentColors';
interface MobileAgentButtonProps {
onCycleAgent: () => void;
onOpenAgentPanel: () => void;
className?: string;
}
export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onOpenAgentPanel, className }) => {
const LONG_PRESS_MS = 500;
export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onOpenAgentPanel, onCycleAgent, className }) => {
const { currentAgentName, getVisibleAgents } = useConfigStore();
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessionAgentName = useSessionStore((state) =>
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
);
const longPressTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressTriggeredRef = React.useRef(false);
const agents = getVisibleAgents();
const uiAgentName = currentSessionId ? (sessionAgentName || currentAgentName) : currentAgentName;
const agentLabel = getAgentDisplayName(agents, uiAgentName);
const agentColor = getAgentColor(uiAgentName);
const clearLongPressTimer = React.useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
}, []);
const startLongPressTimer = React.useCallback(() => {
clearLongPressTimer();
longPressTriggeredRef.current = false;
longPressTimerRef.current = setTimeout(() => {
longPressTriggeredRef.current = true;
onOpenAgentPanel();
}, LONG_PRESS_MS);
}, [clearLongPressTimer, onOpenAgentPanel]);
React.useEffect(() => {
return () => clearLongPressTimer();
}, [clearLongPressTimer]);
return (
<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(
'inline-flex min-w-0 items-center select-none',
'rounded-lg border border-border/50 px-1.5',
'typography-micro font-medium',
'focus:outline-none hover:bg-[var(--interactive-hover)]',
'touch-manipulation active:scale-95 transition-transform',
'touch-none',
className
)}
style={{
@@ -2,10 +2,28 @@ import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
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 { 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 {
onSessionSwitch?: (sessionId: string) => void;
@@ -19,6 +37,13 @@ interface SessionWithStatus extends Session {
_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(
sessions: Session[],
sessionStatus: Map<string, { type: string }> | undefined,
@@ -148,6 +173,88 @@ function useSessionHelpers(
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 }) {
if (isRunning) {
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 }) {
if (count === 0) return null;
return (
<span className="flex items-center gap-0.5 text-xs text-[var(--status-info)]">
<RiLoader4Line className="h-3 w-3 animate-spin" />
{count} running
<span className="flex items-center gap-1 text-[13px] text-[var(--status-info)]">
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
{count}
</span>
);
}
@@ -171,9 +278,9 @@ function RunningIndicator({ count }: { count: number }) {
function UnreadIndicator({ count }: { count: number }) {
if (count === 0) return null;
return (
<span className="flex items-center gap-0.5 text-xs text-[var(--status-error)]">
<div className="h-1.5 w-1.5 rounded-full bg-[var(--status-error)]" />
{count} unread
<span className="flex items-center gap-1 text-[13px] text-[var(--status-error)]">
<div className="h-2 w-2 rounded-full bg-[var(--status-error)]" />
{count}
</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({
currentSessionTitle,
runningCount,
unreadCount,
onToggle
}: {
currentSessionTitle: string;
runningCount: number;
unreadCount: number;
onToggle: () => void;
}) {
const hasActivity = runningCount > 0 || unreadCount > 0;
currentProjectLabel,
currentProjectIcon,
currentProjectColor,
onToggle,
isExpanded = false
}: SessionStatusHeaderProps) {
const ProjectIcon = currentProjectIcon ? PROJECT_ICON_MAP[currentProjectIcon] : null;
const projectColorVar = currentProjectColor ? (PROJECT_COLOR_MAP[currentProjectColor] ?? null) : null;
return (
<button
type="button"
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">
<span className="text-xs text-[var(--surface-foreground)] truncate flex-1 leading-tight">
{currentSessionTitle}
</span>
{hasActivity && (
<div className="flex items-center gap-1 flex-shrink-0">
<RunningIndicator count={runningCount} />
<UnreadIndicator count={unreadCount} />
{!isExpanded && currentProjectLabel && (
<div className="flex flex-col items-start">
<div className="flex items-center gap-1 leading-none">
{ProjectIcon && (
<ProjectIcon
className="h-2.5 w-2.5"
style={projectColorVar ? { color: projectColorVar } : undefined}
/>
)}
<span
className="text-[11px] leading-none text-[var(--surface-mutedForeground)] truncate max-w-[120px]"
style={projectColorVar ? { color: projectColorVar } : undefined}
>
{currentProjectLabel}
</span>
</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>
{/* 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>
);
}
// 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({
runningCount,
unreadCount,
currentSessionTitle,
currentProjectLabel,
currentProjectIcon,
currentProjectColor,
onToggle,
onNewSession,
cornerRadius,
contextUsage,
}: {
runningCount: number;
unreadCount: number;
currentSessionTitle: string;
currentProjectLabel?: string;
currentProjectIcon?: string | null;
currentProjectColor?: string | null;
onToggle: () => void;
onNewSession: () => void;
cornerRadius?: number;
contextUsage: SessionContextUsage | null;
}) {
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
return (
<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={{
borderTopLeftRadius: 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
currentSessionTitle={currentSessionTitle}
runningCount={runningCount}
unreadCount={unreadCount}
currentProjectLabel={currentProjectLabel}
currentProjectIcon={currentProjectIcon}
currentProjectColor={currentProjectColor}
onToggle={onToggle}
/>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
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)] flex-shrink-0 self-center"
>
New
</button>
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
className="flex items-center gap-2"
onClick={onToggle}
>
<RunningIndicator count={runningCount} />
<UnreadIndicator count={unreadCount} />
<TokenUsageIndicator contextUsage={contextUsage} />
</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>
);
}
@@ -349,36 +788,58 @@ function ExpandedView({
runningCount,
unreadCount,
currentSessionTitle,
currentProjectLabel,
currentProjectIcon,
currentProjectColor,
isExpanded,
onToggleCollapse,
onToggleExpand,
onNewSession,
onSessionClick,
onSessionDoubleClick,
onProjectSwitch,
onAddProject,
onRemoveProject,
getSessionAgentName,
getSessionTitle,
needsAttention,
cornerRadius,
contextUsage,
projects,
activeProjectId,
getProjectStatus,
homeDirectory,
}: {
sessions: SessionWithStatus[];
currentSessionId: string;
runningCount: number;
unreadCount: number;
currentSessionTitle: string;
currentProjectLabel?: string;
currentProjectIcon?: string | null;
currentProjectColor?: string | null;
isExpanded: boolean;
onToggleCollapse: () => void;
onToggleExpand: () => void;
onNewSession: () => void;
onSessionClick: (id: string) => void;
onSessionDoubleClick?: () => void;
onProjectSwitch: (projectId: string) => void;
onAddProject: () => void;
onRemoveProject?: (projectId: string) => void;
getSessionAgentName: (s: Session) => string;
getSessionTitle: (s: Session) => string;
needsAttention: (sessionId: string) => boolean;
cornerRadius?: number;
contextUsage: SessionContextUsage | null;
projects: ProjectEntry[];
activeProjectId: string | null;
getProjectStatus: (path: string) => { hasRunning: boolean; hasUnread: boolean };
homeDirectory: string | null;
}) {
const containerRef = React.useRef<HTMLDivElement>(null);
const [collapsedHeight, setCollapsedHeight] = React.useState<number | null>(null);
const [hasMeasured, setHasMeasured] = React.useState(false);
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
React.useEffect(() => {
if (containerRef.current && !hasMeasured && !isExpanded) {
@@ -387,8 +848,36 @@ function ExpandedView({
}
}, [hasMeasured, isExpanded]);
// Filter sessions by active project
const filteredSessions = React.useMemo(() => {
if (!activeProjectId) return sessions;
const activeProject = projects.find(p => p.id === activeProjectId);
if (!activeProject) return sessions;
const projectRoot = normalize(activeProject.path);
const projectDirs = new Set<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 displaySessions = hasMeasured || isExpanded ? sessions : sessions.slice(0, 3);
const displaySessions = hasMeasured || isExpanded
? filteredSessions.filter(s => s.id !== currentSessionId)
: filteredSessions.slice(0, 3);
return (
<div
@@ -397,57 +886,74 @@ function ExpandedView({
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="flex items-center justify-between px-2 py-0">
<div className="flex-1 min-w-0 mr-2">
{/* Header row */}
<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
currentSessionTitle={currentSessionTitle}
runningCount={runningCount}
unreadCount={unreadCount}
currentProjectLabel={currentProjectLabel}
currentProjectIcon={currentProjectIcon}
currentProjectColor={currentProjectColor}
onToggle={onToggleCollapse}
isExpanded={true}
/>
</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
type="button"
onClick={(e) => {
e.stopPropagation();
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
</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>
{/* Project switcher bar */}
<ProjectBar
projects={projects}
activeProjectId={activeProjectId}
getProjectStatus={getProjectStatus}
onProjectSwitch={onProjectSwitch}
onAddProject={onAddProject}
onRemoveProject={onRemoveProject}
homeDirectory={homeDirectory}
/>
{/* Sessions list */}
<div
ref={containerRef}
className="flex flex-col overflow-y-auto"
style={{ maxHeight: isExpanded ? '60vh' : previewHeight }}
>
{displaySessions.map((session) => (
<SessionItem
key={session.id}
session={session}
isCurrent={session.id === currentSessionId}
getSessionAgentName={getSessionAgentName}
getSessionTitle={getSessionTitle}
onClick={() => onSessionClick(session.id)}
onDoubleClick={onSessionDoubleClick}
needsAttention={needsAttention}
/>
))}
{displaySessions.length === 0 ? (
<div className="flex items-center justify-center py-3 text-[11px] text-[var(--surface-mutedForeground)]">
<span>No sessions in this project</span>
</div>
) : (
displaySessions.map((session) => (
<SessionItem
key={session.id}
session={session}
isCurrent={session.id === currentSessionId}
getSessionAgentName={getSessionAgentName}
getSessionTitle={getSessionTitle}
onClick={() => onSessionClick(session.id)}
onDoubleClick={onSessionDoubleClick}
needsAttention={needsAttention}
/>
))
)}
</div>
</div>
);
@@ -462,19 +968,51 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
const sessionStatus = useSessionStore((state) => state.sessionStatus);
const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates);
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
const createSession = useSessionStore((state) => state.createSession);
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const getContextUsage = useSessionStore((state) => state.getContextUsage);
const agents = useConfigStore((state) => state.agents);
const { isMobile, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore();
const { getCurrentModel } = useConfigStore();
const { isMobile, showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore();
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const [isExpanded, setIsExpanded] = React.useState(false);
// Project store
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
const addProject = useProjectsStore((state) => state.addProject);
const removeProject = useProjectsStore((state) => state.removeProject);
const getActiveProject = useProjectsStore((state) => state.getActiveProject);
// Directory store
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus, sessionAttentionStates);
const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus, sessionAttentionStates);
const getProjectStatus = useProjectStatus(sessions, sessionStatus, sessionAttentionStates, currentSessionId);
const currentSession = sessions.find((s) => s.id === currentSessionId);
const currentSessionTitle = currentSession ? getSessionTitle(currentSession) : 'New session';
const currentSessionTitle = currentSession
? getSessionTitle(currentSession)
: '← Swipe here to open sidebars →';
if (!isMobile || totalCount === 0) {
const activeProject = getActiveProject();
const currentProjectLabel = activeProject?.label || formatDirectoryName(activeProject?.path || '', homeDirectory);
const currentProjectIcon = activeProject?.icon;
const currentProjectColor = activeProject?.color;
// Calculate token usage for current session
const currentModel = getCurrentModel();
const limit = currentModel && typeof currentModel.limit === 'object' && currentModel.limit !== null
? (currentModel.limit as Record<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;
}
@@ -489,23 +1027,56 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
setActiveMainTab('chat');
};
const handleCreateSession = async () => {
const newSession = await createSession();
if (newSession) {
setCurrentSession(newSession.id);
onSessionSwitch?.(newSession.id);
const handleCreateSession = () => {
openNewSessionDraft();
};
const handleProjectSwitch = (projectId: string) => {
if (projectId !== activeProjectId) {
setActiveProject(projectId);
}
};
const handleAddProject = () => {
if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) {
sessionEvents.requestDirectoryDialog();
return;
}
import('@/lib/desktop')
.then(({ requestDirectoryAccess }) => requestDirectoryAccess(''))
.then((result) => {
if (result.success && result.path) {
const added = addProject(result.path, { id: result.projectId });
if (!added) {
toast.error('Failed to add project', {
description: 'Please select a valid directory.',
});
}
} else if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', {
description: result.error,
});
}
})
.catch((error) => {
console.error('Failed to select directory:', error);
toast.error('Failed to select directory');
});
};
if (isMobileSessionStatusBarCollapsed) {
return (
<CollapsedView
runningCount={totalRunning}
unreadCount={totalUnread}
currentSessionTitle={currentSessionTitle}
currentProjectLabel={currentProjectLabel}
currentProjectIcon={currentProjectIcon}
currentProjectColor={currentProjectColor}
onToggle={() => setIsMobileSessionStatusBarCollapsed(false)}
onNewSession={handleCreateSession}
cornerRadius={cornerRadius}
contextUsage={contextUsage}
/>
);
}
@@ -517,19 +1088,29 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
runningCount={totalRunning}
unreadCount={totalUnread}
currentSessionTitle={currentSessionTitle}
currentProjectLabel={currentProjectLabel}
currentProjectIcon={currentProjectIcon}
currentProjectColor={currentProjectColor}
isExpanded={isExpanded}
onToggleCollapse={() => {
setIsMobileSessionStatusBarCollapsed(true);
setIsExpanded(false);
}}
onToggleExpand={() => setIsExpanded(!isExpanded)}
onNewSession={handleCreateSession}
onSessionClick={handleSessionClick}
onSessionDoubleClick={handleSessionDoubleClick}
onProjectSwitch={handleProjectSwitch}
onAddProject={handleAddProject}
onRemoveProject={removeProject}
getSessionAgentName={getSessionAgentName}
getSessionTitle={getSessionTitle}
needsAttention={needsAttention}
cornerRadius={cornerRadius}
contextUsage={contextUsage}
projects={projects}
activeProjectId={activeProjectId}
getProjectStatus={getProjectStatus}
homeDirectory={homeDirectory}
/>
);
};
@@ -148,11 +148,6 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isExpanded]);
// Don't render if nothing to show
if (!hasContent) {
return null;
}
const toggleExpanded = () => setIsExpanded((prev) => !prev);
// Abort button for mobile/vscode
@@ -193,9 +188,13 @@ export const StatusRow: React.FC<StatusRowProps> = ({
</button>
) : null;
// Don't render if nothing to show
if (!hasContent) {
return null;
}
return (
<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]">
{/* Left: Abort status or Working placeholder */}
<div className="flex-1 flex items-center overflow-hidden min-w-0">