Adds command-palette style project browsing Keeps native Finder picker as optional desktop action Supports disabled existing projects and create-and-add paths
1576 lines
54 KiB
TypeScript
1576 lines
54 KiB
TypeScript
import React from 'react';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useSelectionStore } from '@/sync/selection-store';
|
|
import { useSessions, useAllSessionStatuses } from '@/sync/sync-context';
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import type { Session } from '@opencode-ai/sdk/v2';
|
|
import type { ProjectEntry } from '@/lib/api/types';
|
|
import { cn, formatDirectoryName } from '@/lib/utils';
|
|
import { getAgentColor } from '@/lib/agentColors';
|
|
import {
|
|
RiLoader4Line,
|
|
RiAddLine,
|
|
RiDragMove2Line,
|
|
RiDeleteBinLine,
|
|
RiEditLine,
|
|
RiArrowUpLine,
|
|
RiArrowDownLine,
|
|
} from '@remixicon/react';
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
} from '@dnd-kit/core';
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
|
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
import { sessionEvents } from '@/lib/sessionEvents';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
|
import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
|
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
|
import { useNotificationStore } from '@/sync/notification-store';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
interface MobileSessionStatusBarProps {
|
|
onSessionSwitch?: (sessionId: string) => void;
|
|
}
|
|
|
|
interface SessionWithStatus extends Session {
|
|
_statusType?: 'busy' | 'retry' | 'idle';
|
|
_hasRunningChildren?: boolean;
|
|
_runningChildrenCount?: number;
|
|
_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: Record<string, { type: string }> | undefined
|
|
) {
|
|
const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
|
|
|
|
const parentChildMap = React.useMemo(() => {
|
|
const map = new Map<string, Session[]>();
|
|
const allIds = new Set(sessions.map((s) => s.id));
|
|
|
|
sessions.forEach((session) => {
|
|
const parentID = (session as { parentID?: string }).parentID;
|
|
if (parentID && allIds.has(parentID)) {
|
|
map.set(parentID, [...(map.get(parentID) || []), session]);
|
|
}
|
|
});
|
|
return map;
|
|
}, [sessions]);
|
|
|
|
const getStatusType = React.useCallback((sessionId: string): 'busy' | 'retry' | 'idle' => {
|
|
const status = sessionStatus?.[sessionId];
|
|
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
|
|
return 'idle';
|
|
}, [sessionStatus]);
|
|
|
|
const hasRunningChildren = React.useCallback((sessionId: string): boolean => {
|
|
const children = parentChildMap.get(sessionId) || [];
|
|
return children.some((child) => getStatusType(child.id) !== 'idle');
|
|
}, [parentChildMap, getStatusType]);
|
|
|
|
const getRunningChildrenCount = React.useCallback((sessionId: string): number => {
|
|
const children = parentChildMap.get(sessionId) || [];
|
|
return children.filter((child) => getStatusType(child.id) !== 'idle').length;
|
|
}, [parentChildMap, getStatusType]);
|
|
|
|
const getChildIndicators = React.useCallback((sessionId: string): Array<{ session: Session; isRunning: boolean }> => {
|
|
const children = parentChildMap.get(sessionId) || [];
|
|
return children
|
|
.filter((child) => getStatusType(child.id) !== 'idle')
|
|
.map((child) => ({ session: child, isRunning: true }))
|
|
.slice(0, 3);
|
|
}, [parentChildMap, getStatusType]);
|
|
|
|
const processedSessions = React.useMemo(() => {
|
|
const sessionIds = new Set(sessions.map((s) => s.id));
|
|
const topLevel = sessions.filter((session) => {
|
|
const parentID = (session as { parentID?: string }).parentID;
|
|
return !parentID || !sessionIds.has(parentID);
|
|
});
|
|
|
|
const running: SessionWithStatus[] = [];
|
|
const viewed: SessionWithStatus[] = [];
|
|
|
|
topLevel.forEach((session) => {
|
|
const statusType = getStatusType(session.id);
|
|
const hasRunning = hasRunningChildren(session.id);
|
|
const attention = (unseenCounts[session.id] ?? 0) > 0;
|
|
|
|
const enriched: SessionWithStatus = {
|
|
...session,
|
|
_statusType: statusType,
|
|
_hasRunningChildren: hasRunning,
|
|
_runningChildrenCount: getRunningChildrenCount(session.id),
|
|
_childIndicators: getChildIndicators(session.id),
|
|
};
|
|
|
|
if (statusType !== 'idle' || hasRunning) {
|
|
running.push(enriched);
|
|
} else if (attention) {
|
|
running.push(enriched);
|
|
} else {
|
|
viewed.push(enriched);
|
|
}
|
|
});
|
|
|
|
const sortByUpdated = (a: Session, b: Session) => {
|
|
const aTime = (a as unknown as { time?: { updated?: number } }).time?.updated ?? 0;
|
|
const bTime = (b as unknown as { time?: { updated?: number } }).time?.updated ?? 0;
|
|
return bTime - aTime;
|
|
};
|
|
|
|
running.sort(sortByUpdated);
|
|
viewed.sort(sortByUpdated);
|
|
|
|
return [...running, ...viewed];
|
|
}, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, unseenCounts]);
|
|
|
|
const totalRunning = processedSessions.reduce((sum, s) => {
|
|
const selfRunning = s._statusType !== 'idle' ? 1 : 0;
|
|
return sum + selfRunning + (s._runningChildrenCount ?? 0);
|
|
}, 0);
|
|
|
|
const totalUnread = processedSessions.filter((s) => (unseenCounts[s.id] ?? 0) > 0).length;
|
|
|
|
return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length };
|
|
}
|
|
|
|
function useSessionHelpers(
|
|
agents: Array<{ name: string }>,
|
|
sessionStatus: Record<string, { type: string }> | undefined
|
|
) {
|
|
const getSessionAgentName = React.useCallback((session: Session): string => {
|
|
const agent = (session as { agent?: string }).agent;
|
|
if (agent) return agent;
|
|
|
|
const sessionAgentSelection = useSelectionStore.getState().getSessionAgentSelection(session.id);
|
|
if (sessionAgentSelection) return sessionAgentSelection;
|
|
|
|
return agents[0]?.name ?? 'agent';
|
|
}, [agents]);
|
|
|
|
const getSessionTitle = React.useCallback((session: Session): string => {
|
|
const title = session.title;
|
|
if (title && title.trim()) return title;
|
|
return 'New session';
|
|
}, []);
|
|
|
|
const isRunning = React.useCallback((sessionId: string): boolean => {
|
|
const status = sessionStatus?.[sessionId];
|
|
return status?.type === 'busy' || status?.type === 'retry';
|
|
}, [sessionStatus]);
|
|
|
|
const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
|
|
const needsAttention = React.useCallback((sessionId: string): boolean => {
|
|
return (unseenCounts[sessionId] ?? 0) > 0;
|
|
}, [unseenCounts]);
|
|
|
|
return { getSessionAgentName, getSessionTitle, isRunning, needsAttention };
|
|
}
|
|
|
|
// Hook to calculate project status indicators
|
|
function useProjectStatus(
|
|
sessions: Session[],
|
|
sessionStatus: Record<string, { type: string }> | undefined,
|
|
currentSessionId: string | null
|
|
) {
|
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
|
const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory);
|
|
const notifUnseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
|
|
|
|
const projectStatusMap = React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => {
|
|
const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => {
|
|
const status = sessionStatus?.[sessionId];
|
|
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
|
|
return 'idle';
|
|
};
|
|
|
|
const projectRoot = normalize(projectPath);
|
|
if (!projectRoot) {
|
|
return { hasRunning: false, hasUnread: false };
|
|
}
|
|
|
|
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 = 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 && (notifUnseenCounts[session.id] ?? 0) > 0) {
|
|
hasUnread = true;
|
|
}
|
|
|
|
if (hasRunning && hasUnread) {
|
|
break;
|
|
}
|
|
}
|
|
if (hasRunning && hasUnread) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return { hasRunning, hasUnread };
|
|
}, [getSessionsByDirectory, availableWorktreesByProject, sessionStatus, notifUnseenCounts, 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)]" />;
|
|
}
|
|
if (needsAttention) {
|
|
return <div className="h-1.5 w-1.5 rounded-full bg-[var(--status-error)]" />;
|
|
}
|
|
return <div className="h-1.5 w-1.5 rounded-full border border-[var(--surface-mutedForeground)]" />;
|
|
}
|
|
|
|
function RunningIndicator({ count }: { count: number }) {
|
|
if (count === 0) return null;
|
|
return (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function UnreadIndicator({ count }: { count: number }) {
|
|
if (count === 0) return null;
|
|
return (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function SessionItem({
|
|
session,
|
|
isCurrent,
|
|
getSessionAgentName,
|
|
getSessionTitle,
|
|
onClick,
|
|
onDoubleClick,
|
|
needsAttention
|
|
}: {
|
|
session: SessionWithStatus;
|
|
isCurrent: boolean;
|
|
getSessionAgentName: (s: Session) => string;
|
|
getSessionTitle: (s: Session) => string;
|
|
onClick: () => void;
|
|
onDoubleClick?: () => void;
|
|
needsAttention: (sessionId: string) => boolean;
|
|
}) {
|
|
const agentName = getSessionAgentName(session);
|
|
const agentColor = getAgentColor(agentName);
|
|
const extraCount = (session._runningChildrenCount || 0) + (session._statusType !== 'idle' ? 1 : 0) - 1 - (session._childIndicators?.length || 0);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation();
|
|
onDoubleClick?.();
|
|
}}
|
|
className={cn(
|
|
"flex items-center gap-0.5 px-1.5 py-px text-left transition-colors",
|
|
"hover:bg-[var(--interactive-hover)] active:bg-[var(--interactive-selection)]",
|
|
isCurrent && "bg-[var(--interactive-selection)]/30"
|
|
)}
|
|
>
|
|
<div className="flex-shrink-0 w-3 flex items-center justify-center">
|
|
<StatusIndicator
|
|
isRunning={session._statusType !== 'idle'}
|
|
needsAttention={needsAttention(session.id)}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className="flex-shrink-0 h-1.5 w-1.5 rounded-full"
|
|
style={{ backgroundColor: `var(${agentColor.var})` }}
|
|
/>
|
|
|
|
<span className={cn(
|
|
"text-[13px] truncate leading-tight",
|
|
isCurrent ? "text-[var(--interactive-selection-foreground)] font-medium" : "text-[var(--surface-foreground)]"
|
|
)}>
|
|
{getSessionTitle(session)}
|
|
</span>
|
|
|
|
{(session._childIndicators?.length || 0) > 0 && (
|
|
<div className="flex items-center gap-0.5 text-[var(--surface-mutedForeground)]">
|
|
<span className="text-[10px]">[</span>
|
|
<div className="flex items-center gap-0.5">
|
|
{session._childIndicators!.map(({ session: child }) => {
|
|
const childColor = getAgentColor(getSessionAgentName(child));
|
|
return (
|
|
<div
|
|
key={child.id}
|
|
className="flex-shrink-0"
|
|
title={`Sub-session: ${getSessionTitle(child)}`}
|
|
>
|
|
<RiLoader4Line
|
|
className="h-2.5 w-2.5 animate-spin"
|
|
style={{ color: `var(${childColor.var})` }}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
{extraCount > 0 && (
|
|
<span className="text-[10px] text-[var(--surface-mutedForeground)]">
|
|
+{extraCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[10px]">]</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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;
|
|
currentProjectIconImageUrl?: string | null;
|
|
currentProjectIconBackground?: string | null;
|
|
currentProjectColor?: string | null;
|
|
onToggle: () => void;
|
|
isExpanded?: boolean;
|
|
childIndicators?: Array<{ session: Session; isRunning: boolean }>;
|
|
}
|
|
|
|
function SessionStatusHeader({
|
|
currentSessionTitle,
|
|
currentProjectLabel,
|
|
currentProjectIcon,
|
|
currentProjectIconImageUrl,
|
|
currentProjectIconBackground,
|
|
currentProjectColor,
|
|
onToggle,
|
|
isExpanded = false,
|
|
childIndicators = []
|
|
}: SessionStatusHeaderProps) {
|
|
const [imageFailed, setImageFailed] = React.useState(false);
|
|
const ProjectIcon = currentProjectIcon ? PROJECT_ICON_MAP[currentProjectIcon] : null;
|
|
const imageUrl = !imageFailed ? currentProjectIconImageUrl : null;
|
|
const projectColorVar = currentProjectColor ? (PROJECT_COLOR_MAP[currentProjectColor] ?? null) : null;
|
|
const extraCount = childIndicators.length > 3 ? childIndicators.length - 3 : 0;
|
|
|
|
React.useEffect(() => {
|
|
setImageFailed(false);
|
|
}, [currentProjectIconImageUrl]);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
className="w-full flex flex-col px-2 py-0.5 text-left transition-colors hover:bg-[var(--interactive-hover)]"
|
|
>
|
|
{!isExpanded && currentProjectLabel && (
|
|
<div className="flex flex-col items-start">
|
|
<div className="flex items-center gap-1 leading-none">
|
|
{imageUrl ? (
|
|
<span
|
|
className="inline-flex h-2.5 w-2.5 items-center justify-center overflow-hidden rounded-[1px]"
|
|
style={currentProjectIconBackground ? { backgroundColor: currentProjectIconBackground } : undefined}
|
|
>
|
|
<img
|
|
src={imageUrl}
|
|
alt=""
|
|
className="h-full w-full object-contain"
|
|
draggable={false}
|
|
onError={() => setImageFailed(true)}
|
|
/>
|
|
</span>
|
|
) : 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>
|
|
)}
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<span className="flex-1 min-w-0 text-[13px] text-[var(--surface-foreground)] truncate leading-none">
|
|
{currentSessionTitle}
|
|
</span>
|
|
{childIndicators.length > 0 && (
|
|
<div className="flex items-center gap-0.5 text-[var(--surface-mutedForeground)]">
|
|
<span className="text-[10px]">[</span>
|
|
<div className="flex items-center gap-0.5">
|
|
{childIndicators.slice(0, 3).map((child) => {
|
|
const childAgent = (child.session as { agent?: string }).agent || 'agent';
|
|
const childColor = getAgentColor(childAgent);
|
|
return (
|
|
<div
|
|
key={child.session.id}
|
|
className="flex-shrink-0"
|
|
title={`Sub-session: ${child.session.title || 'Untitled'}`}
|
|
>
|
|
<RiLoader4Line
|
|
className="h-2.5 w-2.5 animate-spin"
|
|
style={{ color: `var(${childColor.var})` }}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
{extraCount > 0 && (
|
|
<span className="text-[10px] text-[var(--surface-mutedForeground)]">
|
|
+{extraCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[10px]">]</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
|
|
|
|
// Hook for long press with movement detection
|
|
function useLongPress(
|
|
onLongPress: () => void,
|
|
onClick: () => void,
|
|
ms = 500
|
|
) {
|
|
const timerRef = React.useRef<NodeJS.Timeout | null>(null);
|
|
const isLongPress = React.useRef(false);
|
|
const startPosRef = React.useRef<{ x: number; y: number } | null>(null);
|
|
const hasMovedRef = React.useRef(false);
|
|
const MOVE_THRESHOLD = 10; // pixels
|
|
|
|
const start = React.useCallback((clientX: number, clientY: number) => {
|
|
isLongPress.current = false;
|
|
hasMovedRef.current = false;
|
|
startPosRef.current = { x: clientX, y: clientY };
|
|
timerRef.current = setTimeout(() => {
|
|
if (!hasMovedRef.current) {
|
|
isLongPress.current = true;
|
|
onLongPress();
|
|
}
|
|
}, ms);
|
|
}, [onLongPress, ms]);
|
|
|
|
const move = React.useCallback((clientX: number, clientY: number) => {
|
|
if (!startPosRef.current) return;
|
|
|
|
const dx = Math.abs(clientX - startPosRef.current.x);
|
|
const dy = Math.abs(clientY - startPosRef.current.y);
|
|
|
|
if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) {
|
|
hasMovedRef.current = true;
|
|
if (timerRef.current) {
|
|
clearTimeout(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const end = React.useCallback(() => {
|
|
if (timerRef.current) {
|
|
clearTimeout(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
startPosRef.current = null;
|
|
}, []);
|
|
|
|
const handleClick = React.useCallback(() => {
|
|
if (!isLongPress.current) {
|
|
onClick();
|
|
}
|
|
}, [onClick]);
|
|
|
|
return {
|
|
onMouseDown: (e: React.MouseEvent) => start(e.clientX, e.clientY),
|
|
onMouseUp: end,
|
|
onMouseLeave: end,
|
|
onMouseMove: (e: React.MouseEvent) => move(e.clientX, e.clientY),
|
|
onTouchStart: (e: React.TouchEvent) => {
|
|
const touch = e.touches[0];
|
|
start(touch.clientX, touch.clientY);
|
|
},
|
|
onTouchMove: (e: React.TouchEvent) => {
|
|
const touch = e.touches[0];
|
|
move(touch.clientX, touch.clientY);
|
|
},
|
|
onTouchEnd: end,
|
|
onClick: handleClick,
|
|
};
|
|
}
|
|
|
|
// Sortable project item for edit panel
|
|
interface SortableProjectItemProps {
|
|
project: ProjectEntry;
|
|
isFirst: boolean;
|
|
isLast: boolean;
|
|
onMoveUp: () => void;
|
|
onMoveDown: () => void;
|
|
onEdit: () => void;
|
|
onDelete: () => void;
|
|
formatProjectLabel: (project: ProjectEntry) => string;
|
|
}
|
|
|
|
function SortableProjectItem({
|
|
project,
|
|
isFirst,
|
|
isLast,
|
|
onMoveUp,
|
|
onMoveDown,
|
|
onEdit,
|
|
onDelete,
|
|
formatProjectLabel,
|
|
}: SortableProjectItemProps) {
|
|
const { currentTheme } = useThemeSystem();
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: project.id });
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
zIndex: isDragging ? 10 : 1,
|
|
};
|
|
|
|
const [imageFailed, setImageFailed] = React.useState(false);
|
|
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
|
const projectIconImageUrl = !imageFailed
|
|
? getProjectIconImageUrl(project, {
|
|
themeVariant: currentTheme.metadata.variant,
|
|
iconColor: currentTheme.colors.surface.foreground,
|
|
})
|
|
: null;
|
|
const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={cn(
|
|
"flex items-center gap-2 p-3 bg-[var(--surface-elevated)] rounded-lg border border-[var(--interactive-border)]",
|
|
isDragging && "shadow-lg opacity-90"
|
|
)}
|
|
>
|
|
{/* Drag handle */}
|
|
<button
|
|
type="button"
|
|
className="flex-shrink-0 p-1.5 text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] cursor-grab active:cursor-grabbing touch-none"
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
<RiDragMove2Line className="h-4 w-4" />
|
|
</button>
|
|
|
|
{/* Project info */}
|
|
<div className="flex-1 flex items-center gap-2 min-w-0">
|
|
{projectIconImageUrl ? (
|
|
<span
|
|
className="inline-flex h-5 w-5 items-center justify-center overflow-hidden rounded-[2px] flex-shrink-0"
|
|
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
|
|
>
|
|
<img
|
|
src={projectIconImageUrl}
|
|
alt=""
|
|
className="h-full w-full object-contain"
|
|
draggable={false}
|
|
onError={() => setImageFailed(true)}
|
|
/>
|
|
</span>
|
|
) : ProjectIcon ? (
|
|
<ProjectIcon
|
|
className="h-5 w-5 flex-shrink-0"
|
|
style={projectColorVar ? { color: projectColorVar } : undefined}
|
|
/>
|
|
) : (
|
|
<div className="h-5 w-5 rounded bg-[var(--surface-muted)] flex-shrink-0" />
|
|
)}
|
|
<span className="text-sm text-[var(--surface-foreground)] truncate">
|
|
{formatProjectLabel(project)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-1">
|
|
{/* Move up/down buttons (for non-drag sorting) */}
|
|
<button
|
|
type="button"
|
|
onClick={onMoveUp}
|
|
disabled={isFirst}
|
|
className="p-1.5 rounded text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
<RiArrowUpLine className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onMoveDown}
|
|
disabled={isLast}
|
|
className="p-1.5 rounded text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
<RiArrowDownLine className="h-4 w-4" />
|
|
</button>
|
|
|
|
<div className="w-px h-5 bg-[var(--interactive-border)] mx-1" />
|
|
|
|
{/* Edit button */}
|
|
<button
|
|
type="button"
|
|
onClick={onEdit}
|
|
className="p-1.5 rounded text-[var(--surface-mutedForeground)] hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10"
|
|
>
|
|
<RiEditLine className="h-4 w-4" />
|
|
</button>
|
|
|
|
{/* Delete button */}
|
|
<button
|
|
type="button"
|
|
onClick={onDelete}
|
|
className="p-1.5 rounded text-[var(--surface-mutedForeground)] hover:text-[var(--status-error)] hover:bg-[var(--status-error)]/10"
|
|
>
|
|
<RiDeleteBinLine className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Project edit panel for mobile
|
|
interface ProjectEditPanelProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
projects: ProjectEntry[];
|
|
onReorder: (fromIndex: number, toIndex: number) => void;
|
|
onEdit: (project: ProjectEntry) => void;
|
|
onDelete: (project: ProjectEntry) => void;
|
|
homeDirectory: string | null;
|
|
}
|
|
|
|
function ProjectEditPanel({
|
|
isOpen,
|
|
onClose,
|
|
projects,
|
|
onReorder,
|
|
onEdit,
|
|
onDelete,
|
|
homeDirectory,
|
|
}: ProjectEditPanelProps) {
|
|
const { t } = useI18n();
|
|
const [localProjects, setLocalProjects] = React.useState(projects);
|
|
|
|
React.useEffect(() => {
|
|
setLocalProjects(projects);
|
|
}, [projects, isOpen]);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: {
|
|
distance: 8,
|
|
},
|
|
}),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
})
|
|
);
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (over && active.id !== over.id) {
|
|
const oldIndex = localProjects.findIndex((p) => p.id === active.id);
|
|
const newIndex = localProjects.findIndex((p) => p.id === over.id);
|
|
|
|
setLocalProjects((items) => arrayMove(items, oldIndex, newIndex));
|
|
onReorder(oldIndex, newIndex);
|
|
}
|
|
};
|
|
|
|
const handleMoveUp = (index: number) => {
|
|
if (index > 0) {
|
|
setLocalProjects((items) => arrayMove(items, index, index - 1));
|
|
onReorder(index, index - 1);
|
|
}
|
|
};
|
|
|
|
const handleMoveDown = (index: number) => {
|
|
if (index < localProjects.length - 1) {
|
|
setLocalProjects((items) => arrayMove(items, index, index + 1));
|
|
onReorder(index, index + 1);
|
|
}
|
|
};
|
|
|
|
const formatProjectLabel = (project: ProjectEntry): string => {
|
|
return project.label?.trim()
|
|
|| formatDirectoryName(project.path, homeDirectory)
|
|
|| project.path;
|
|
};
|
|
|
|
return (
|
|
<MobileOverlayPanel
|
|
open={isOpen}
|
|
onClose={onClose}
|
|
title={t('chat.mobileStatus.editProjects.title')}
|
|
footer={
|
|
<p className="text-xs text-[var(--surface-mutedForeground)] text-center">
|
|
{t('chat.mobileStatus.editProjects.footer')}
|
|
</p>
|
|
}
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={localProjects.map((p) => p.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
{localProjects.map((project, index) => (
|
|
<SortableProjectItem
|
|
key={project.id}
|
|
project={project}
|
|
isFirst={index === 0}
|
|
isLast={index === localProjects.length - 1}
|
|
onMoveUp={() => handleMoveUp(index)}
|
|
onMoveDown={() => handleMoveDown(index)}
|
|
onEdit={() => onEdit(project)}
|
|
onDelete={() => onDelete(project)}
|
|
formatProjectLabel={formatProjectLabel}
|
|
/>
|
|
))}
|
|
</SortableContext>
|
|
</DndContext>
|
|
|
|
{localProjects.length === 0 && (
|
|
<div className="text-center py-8 text-[var(--surface-mutedForeground)]">
|
|
{t('chat.mobileStatus.editProjects.empty')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</MobileOverlayPanel>
|
|
);
|
|
}
|
|
|
|
// Project button component with long press support
|
|
interface ProjectButtonProps {
|
|
project: ProjectEntry;
|
|
isActive: boolean;
|
|
status: { hasRunning: boolean; hasUnread: boolean };
|
|
projectColorVar: string | null;
|
|
onProjectSwitch: () => void;
|
|
onOpenEditPanel?: () => void;
|
|
formatProjectLabel: (project: ProjectEntry) => string;
|
|
}
|
|
|
|
function ProjectButton({
|
|
project,
|
|
isActive,
|
|
status,
|
|
projectColorVar,
|
|
onProjectSwitch,
|
|
onOpenEditPanel,
|
|
formatProjectLabel,
|
|
}: ProjectButtonProps) {
|
|
const { currentTheme } = useThemeSystem();
|
|
const [imageFailed, setImageFailed] = React.useState(false);
|
|
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
|
const projectIconImageUrl = !imageFailed
|
|
? getProjectIconImageUrl(project, {
|
|
themeVariant: currentTheme.metadata.variant,
|
|
iconColor: currentTheme.colors.surface.foreground,
|
|
})
|
|
: null;
|
|
|
|
React.useEffect(() => {
|
|
setImageFailed(false);
|
|
}, [project.id, project.iconImage?.updatedAt]);
|
|
|
|
const longPressHandlers = useLongPress(
|
|
() => {
|
|
if (onOpenEditPanel) {
|
|
onOpenEditPanel();
|
|
}
|
|
},
|
|
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 */}
|
|
{projectIconImageUrl ? (
|
|
<span
|
|
className="inline-flex h-3.5 w-3.5 items-center justify-center overflow-hidden rounded-[2px]"
|
|
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
|
|
>
|
|
<img
|
|
src={projectIconImageUrl}
|
|
alt=""
|
|
className="h-full w-full object-contain"
|
|
draggable={false}
|
|
onError={() => setImageFailed(true)}
|
|
/>
|
|
</span>
|
|
) : 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 { t } = useI18n();
|
|
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
const [editPanelOpen, setEditPanelOpen] = React.useState(false);
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
|
const [projectToDelete, setProjectToDelete] = React.useState<ProjectEntry | null>(null);
|
|
const reorderProjects = useProjectsStore((state) => state.reorderProjects);
|
|
|
|
// 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 handleOpenEditPanel = () => {
|
|
setEditPanelOpen(true);
|
|
};
|
|
|
|
const handleReorder = (fromIndex: number, toIndex: number) => {
|
|
reorderProjects(fromIndex, toIndex);
|
|
};
|
|
|
|
const [editingProject, setEditingProject] = React.useState<ProjectEntry | null>(null);
|
|
const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta);
|
|
|
|
const handleEditProject = (project: ProjectEntry) => {
|
|
setEditingProject(project);
|
|
};
|
|
|
|
const handleSaveProjectEdit = (data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => {
|
|
if (editingProject) {
|
|
updateProjectMeta(editingProject.id, data);
|
|
}
|
|
setEditingProject(null);
|
|
};
|
|
|
|
const handleDeleteProject = (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)]">{t('chat.mobileStatus.projects.empty')}</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={t('chat.mobileStatus.projects.addAria')}
|
|
>
|
|
<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)}
|
|
onOpenEditPanel={handleOpenEditPanel}
|
|
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={t('chat.mobileStatus.projects.addAria')}
|
|
>
|
|
<RiAddLine className="h-3.5 w-3.5" />
|
|
</button>
|
|
|
|
{/* Delete confirmation dialog */}
|
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('chat.mobileStatus.projects.removeTitle')}</DialogTitle>
|
|
<DialogDescription>
|
|
{t('chat.mobileStatus.projects.removeDescriptionPrefix')} <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)}>
|
|
{t('chat.mobileStatus.projects.cancel')}
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleConfirmDelete}>
|
|
{t('chat.mobileStatus.projects.remove')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Project edit panel */}
|
|
<ProjectEditPanel
|
|
isOpen={editPanelOpen}
|
|
onClose={() => setEditPanelOpen(false)}
|
|
projects={projects}
|
|
onReorder={handleReorder}
|
|
onEdit={handleEditProject}
|
|
onDelete={handleDeleteProject}
|
|
homeDirectory={homeDirectory}
|
|
/>
|
|
|
|
{/* Project edit dialog */}
|
|
{editingProject && (
|
|
<ProjectEditDialog
|
|
open={!!editingProject}
|
|
onOpenChange={(open) => {
|
|
if (!open) setEditingProject(null);
|
|
}}
|
|
projectId={editingProject.id}
|
|
projectName={editingProject.label || formatDirectoryName(editingProject.path, homeDirectory)}
|
|
projectPath={editingProject.path}
|
|
initialIcon={editingProject.icon}
|
|
initialColor={editingProject.color}
|
|
initialIconBackground={editingProject.iconBackground}
|
|
onSave={handleSaveProjectEdit}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CollapsedView({
|
|
runningCount,
|
|
unreadCount,
|
|
currentSessionTitle,
|
|
currentProjectLabel,
|
|
currentProjectIcon,
|
|
currentProjectIconImageUrl,
|
|
currentProjectIconBackground,
|
|
currentProjectColor,
|
|
onToggle,
|
|
onNewSession,
|
|
contextUsage,
|
|
childIndicators = [],
|
|
}: {
|
|
runningCount: number;
|
|
unreadCount: number;
|
|
currentSessionTitle: string;
|
|
currentProjectLabel?: string;
|
|
currentProjectIcon?: string | null;
|
|
currentProjectIconImageUrl?: string | null;
|
|
currentProjectIconBackground?: string | null;
|
|
currentProjectColor?: string | null;
|
|
onToggle: () => void;
|
|
onNewSession: () => void;
|
|
contextUsage: SessionContextUsage | null;
|
|
childIndicators?: Array<{ session: Session; isRunning: boolean }>;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
|
|
|
|
return (
|
|
<div
|
|
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: 'var(--radius-lg)',
|
|
borderTopRightRadius: 'var(--radius-lg)',
|
|
}}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
>
|
|
<div className="flex-1 min-w-0 mr-1">
|
|
<SessionStatusHeader
|
|
currentSessionTitle={currentSessionTitle}
|
|
currentProjectLabel={currentProjectLabel}
|
|
currentProjectIcon={currentProjectIcon}
|
|
currentProjectIconImageUrl={currentProjectIconImageUrl}
|
|
currentProjectIconBackground={currentProjectIconBackground}
|
|
currentProjectColor={currentProjectColor}
|
|
onToggle={onToggle}
|
|
childIndicators={childIndicators}
|
|
/>
|
|
</div>
|
|
<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"
|
|
>
|
|
{t('chat.mobileStatus.new')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ExpandedView({
|
|
sessions,
|
|
currentSessionId,
|
|
runningCount,
|
|
unreadCount,
|
|
currentSessionTitle,
|
|
currentProjectLabel,
|
|
currentProjectIcon,
|
|
currentProjectIconImageUrl,
|
|
currentProjectIconBackground,
|
|
currentProjectColor,
|
|
isExpanded,
|
|
onToggleCollapse,
|
|
onNewSession,
|
|
onSessionClick,
|
|
onSessionDoubleClick,
|
|
onProjectSwitch,
|
|
onAddProject,
|
|
onRemoveProject,
|
|
getSessionAgentName,
|
|
getSessionTitle,
|
|
needsAttention,
|
|
contextUsage,
|
|
projects,
|
|
activeProjectId,
|
|
getProjectStatus,
|
|
homeDirectory,
|
|
childIndicators = [],
|
|
}: {
|
|
sessions: SessionWithStatus[];
|
|
currentSessionId: string;
|
|
runningCount: number;
|
|
unreadCount: number;
|
|
currentSessionTitle: string;
|
|
currentProjectLabel?: string;
|
|
currentProjectIcon?: string | null;
|
|
currentProjectIconImageUrl?: string | null;
|
|
currentProjectIconBackground?: string | null;
|
|
currentProjectColor?: string | null;
|
|
isExpanded: boolean;
|
|
onToggleCollapse: () => 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;
|
|
contextUsage: SessionContextUsage | null;
|
|
projects: ProjectEntry[];
|
|
activeProjectId: string | null;
|
|
getProjectStatus: (path: string) => { hasRunning: boolean; hasUnread: boolean };
|
|
homeDirectory: string | null;
|
|
childIndicators?: Array<{ session: Session; isRunning: boolean }>;
|
|
}) {
|
|
const { t } = useI18n();
|
|
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 = useSessionUIStore((state) => state.availableWorktreesByProject);
|
|
|
|
React.useEffect(() => {
|
|
if (containerRef.current && !hasMeasured && !isExpanded) {
|
|
setCollapsedHeight(containerRef.current.offsetHeight);
|
|
setHasMeasured(true);
|
|
}
|
|
}, [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
|
|
? filteredSessions.filter(s => s.id !== currentSessionId)
|
|
: filteredSessions.slice(0, 3);
|
|
|
|
return (
|
|
<div
|
|
className="w-full border-b border-[var(--interactive-border)] bg-[var(--surface-muted)] order-first overflow-hidden"
|
|
style={{
|
|
borderTopLeftRadius: 'var(--radius-lg)',
|
|
borderTopRightRadius: 'var(--radius-lg)',
|
|
}}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
>
|
|
{/* 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}
|
|
currentProjectLabel={currentProjectLabel}
|
|
currentProjectIcon={currentProjectIcon}
|
|
currentProjectIconImageUrl={currentProjectIconImageUrl}
|
|
currentProjectIconBackground={currentProjectIconBackground}
|
|
currentProjectColor={currentProjectColor}
|
|
onToggle={onToggleCollapse}
|
|
isExpanded={true}
|
|
childIndicators={childIndicators}
|
|
/>
|
|
</div>
|
|
<div
|
|
className="flex items-center gap-2 flex-shrink-0 cursor-pointer !min-h-0"
|
|
onClick={onToggleCollapse}
|
|
tabIndex={0}
|
|
role="button"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onToggleCollapse();
|
|
}
|
|
}}
|
|
>
|
|
<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-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"
|
|
>
|
|
{t('chat.mobileStatus.new')}
|
|
</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.length === 0 ? (
|
|
<div className="flex items-center justify-center py-3 text-[11px] text-[var(--surface-mutedForeground)]">
|
|
<span>{t('chat.mobileStatus.noSessionsInProject')}</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>
|
|
);
|
|
}
|
|
|
|
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|
onSessionSwitch,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const { currentTheme } = useThemeSystem();
|
|
const sessions = useSessions();
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
|
const sessionStatus = useAllSessionStatuses();
|
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
|
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
|
const agents = useConfigStore((state) => state.agents);
|
|
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
|
|
const isMobile = useUIStore((state) => state.isMobile);
|
|
const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar);
|
|
const isMobileSessionStatusBarCollapsed = useUIStore((state) => state.isMobileSessionStatusBarCollapsed);
|
|
const setIsMobileSessionStatusBarCollapsed = useUIStore((state) => state.setIsMobileSessionStatusBarCollapsed);
|
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
|
|
|
// Project store
|
|
const projects = useProjectsStore((state) => state.projects);
|
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
|
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
|
|
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);
|
|
const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus);
|
|
const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId);
|
|
|
|
const currentSession = sessions.find((s) => s.id === currentSessionId);
|
|
const currentSessionTitle = currentSession
|
|
? getSessionTitle(currentSession)
|
|
: t('chat.mobileStatus.swipeHint');
|
|
|
|
// Calculate current session's child indicators
|
|
const currentSessionWithStatus = sortedSessions.find((s) => s.id === currentSessionId);
|
|
const currentSessionChildIndicators = currentSessionWithStatus?._childIndicators ?? [];
|
|
|
|
const activeProject = getActiveProject();
|
|
const currentProjectLabel = activeProject?.label || formatDirectoryName(activeProject?.path || '', homeDirectory);
|
|
const currentProjectIcon = activeProject?.icon;
|
|
const currentProjectIconImageUrl = activeProject
|
|
? getProjectIconImageUrl(activeProject, {
|
|
themeVariant: currentTheme.metadata.variant,
|
|
iconColor: currentTheme.colors.surface.foreground,
|
|
})
|
|
: null;
|
|
const currentProjectIconBackground = activeProject?.iconBackground ?? null;
|
|
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);
|
|
|
|
if (!isMobile || !showMobileSessionStatusBar || totalCount === 0) {
|
|
return null;
|
|
}
|
|
|
|
const handleSessionClick = (sessionId: string) => {
|
|
setCurrentSession(sessionId);
|
|
onSessionSwitch?.(sessionId);
|
|
setIsExpanded(false);
|
|
};
|
|
|
|
const handleSessionDoubleClick = () => {
|
|
// On double-tap, switch to the Chat tab
|
|
setActiveMainTab('chat');
|
|
};
|
|
|
|
const handleCreateSession = () => {
|
|
openNewSessionDraft();
|
|
};
|
|
|
|
const handleProjectSwitch = (projectId: string) => {
|
|
if (projectId !== activeProjectId) {
|
|
setActiveProject(projectId);
|
|
}
|
|
};
|
|
|
|
const handleAddProject = () => {
|
|
sessionEvents.requestDirectoryDialog();
|
|
};
|
|
|
|
if (isMobileSessionStatusBarCollapsed) {
|
|
return (
|
|
<CollapsedView
|
|
runningCount={totalRunning}
|
|
unreadCount={totalUnread}
|
|
currentSessionTitle={currentSessionTitle}
|
|
currentProjectLabel={currentProjectLabel}
|
|
currentProjectIcon={currentProjectIcon}
|
|
currentProjectIconImageUrl={currentProjectIconImageUrl}
|
|
currentProjectIconBackground={currentProjectIconBackground}
|
|
currentProjectColor={currentProjectColor}
|
|
onToggle={() => setIsMobileSessionStatusBarCollapsed(false)}
|
|
onNewSession={handleCreateSession}
|
|
contextUsage={contextUsage}
|
|
childIndicators={currentSessionChildIndicators}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ExpandedView
|
|
sessions={sortedSessions}
|
|
currentSessionId={currentSessionId ?? ''}
|
|
runningCount={totalRunning}
|
|
unreadCount={totalUnread}
|
|
currentSessionTitle={currentSessionTitle}
|
|
currentProjectLabel={currentProjectLabel}
|
|
currentProjectIcon={currentProjectIcon}
|
|
currentProjectIconImageUrl={currentProjectIconImageUrl}
|
|
currentProjectIconBackground={currentProjectIconBackground}
|
|
currentProjectColor={currentProjectColor}
|
|
isExpanded={isExpanded}
|
|
onToggleCollapse={() => {
|
|
setIsMobileSessionStatusBarCollapsed(true);
|
|
setIsExpanded(false);
|
|
}}
|
|
onNewSession={handleCreateSession}
|
|
onSessionClick={handleSessionClick}
|
|
onSessionDoubleClick={handleSessionDoubleClick}
|
|
onProjectSwitch={handleProjectSwitch}
|
|
onAddProject={handleAddProject}
|
|
onRemoveProject={removeProject}
|
|
getSessionAgentName={getSessionAgentName}
|
|
getSessionTitle={getSessionTitle}
|
|
needsAttention={needsAttention}
|
|
contextUsage={contextUsage}
|
|
projects={projects}
|
|
activeProjectId={activeProjectId}
|
|
getProjectStatus={getProjectStatus}
|
|
homeDirectory={homeDirectory}
|
|
childIndicators={currentSessionChildIndicators}
|
|
/>
|
|
);
|
|
};
|