diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx index bff4e303..ec25747c 100644 --- a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx @@ -7,7 +7,32 @@ 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 } from '@remixicon/react'; +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'; @@ -23,7 +48,9 @@ import { 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'; interface MobileSessionStatusBarProps { onSessionSwitch?: (sessionId: string) => void; @@ -486,7 +513,7 @@ function SessionStatusHeader({ -// Hook for long press +// Hook for long press with movement detection function useLongPress( onLongPress: () => void, onClick: () => void, @@ -494,20 +521,43 @@ function useLongPress( ) { const timerRef = React.useRef(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(() => { + const start = React.useCallback((clientX: number, clientY: number) => { isLongPress.current = false; + hasMovedRef.current = false; + startPosRef.current = { x: clientX, y: clientY }; timerRef.current = setTimeout(() => { - isLongPress.current = true; - onLongPress(); + 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(() => { @@ -517,15 +567,271 @@ function useLongPress( }, [onClick]); return { - onMouseDown: start, + onMouseDown: (e: React.MouseEvent) => start(e.clientX, e.clientY), onMouseUp: end, onMouseLeave: end, - onTouchStart: start, + 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 { + 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) : null; + const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null; + + return ( +
+ {/* Drag handle */} + + + {/* Project info */} +
+ {projectIconImageUrl ? ( + + setImageFailed(true)} + /> + + ) : ProjectIcon ? ( + + ) : ( +
+ )} + + {formatProjectLabel(project)} + +
+ + {/* Actions */} +
+ {/* Move up/down buttons (for non-drag sorting) */} + + + +
+ + {/* Edit button */} + + + {/* Delete button */} + +
+
+ ); +} + +// 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 [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 ( + + Drag items to reorder, or use arrows to move. Tap edit to change details. +

+ } + > +
+ + p.id)} + strategy={verticalListSortingStrategy} + > + {localProjects.map((project, index) => ( + handleMoveUp(index)} + onMoveDown={() => handleMoveDown(index)} + onEdit={() => onEdit(project)} + onDelete={() => onDelete(project)} + formatProjectLabel={formatProjectLabel} + /> + ))} + + + + {localProjects.length === 0 && ( +
+ No projects to edit +
+ )} +
+
+ ); +} + // Project button component with long press support interface ProjectButtonProps { project: ProjectEntry; @@ -533,7 +839,7 @@ interface ProjectButtonProps { status: { hasRunning: boolean; hasUnread: boolean }; projectColorVar: string | null; onProjectSwitch: () => void; - onRemoveProject?: () => void; + onOpenEditPanel?: () => void; formatProjectLabel: (project: ProjectEntry) => string; } @@ -543,7 +849,7 @@ function ProjectButton({ status, projectColorVar, onProjectSwitch, - onRemoveProject, + onOpenEditPanel, formatProjectLabel, }: ProjectButtonProps) { const [imageFailed, setImageFailed] = React.useState(false); @@ -556,8 +862,8 @@ function ProjectButton({ const longPressHandlers = useLongPress( () => { - if (onRemoveProject) { - onRemoveProject(); + if (onOpenEditPanel) { + onOpenEditPanel(); } }, onProjectSwitch, @@ -639,8 +945,10 @@ function ProjectBar({ homeDirectory }: ProjectBarProps) { const scrollRef = React.useRef(null); + const [editPanelOpen, setEditPanelOpen] = React.useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); const [projectToDelete, setProjectToDelete] = React.useState(null); + const reorderProjects = useProjectsStore((state) => state.reorderProjects); // Scroll active project into view React.useEffect(() => { @@ -652,7 +960,29 @@ function ProjectBar({ } }, [activeProjectId]); - const handleLongPress = (project: ProjectEntry) => { + const handleOpenEditPanel = () => { + setEditPanelOpen(true); + }; + + const handleReorder = (fromIndex: number, toIndex: number) => { + reorderProjects(fromIndex, toIndex); + }; + + const [editingProject, setEditingProject] = React.useState(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); }; @@ -737,7 +1067,7 @@ function ProjectBar({ status={status} projectColorVar={projectColorVar} onProjectSwitch={() => onProjectSwitch(project.id)} - onRemoveProject={onRemoveProject ? () => handleLongPress(project) : undefined} + onOpenEditPanel={handleOpenEditPanel} formatProjectLabel={formatProjectLabel} /> ); @@ -773,6 +1103,34 @@ function ProjectBar({ + + {/* Project edit panel */} + setEditPanelOpen(false)} + projects={projects} + onReorder={handleReorder} + onEdit={handleEditProject} + onDelete={handleDeleteProject} + homeDirectory={homeDirectory} + /> + + {/* Project edit dialog */} + {editingProject && ( + { + 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} + /> + )}
); }