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 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">
|
||||
|
||||
Reference in New Issue
Block a user