From 855412ce79269bd81e984e1d845828776002d75e Mon Sep 17 00:00:00 2001
From: Jovines <1246634075@qq.com>
Date: Tue, 3 Mar 2026 19:44:05 +0800
Subject: [PATCH] feat(mobile): add project edit panel on long press (#584)
- Add draggable project list with reorder support
- Use MobileOverlayPanel for consistent bottom sheet UI
- Enhance useLongPress hook with movement detection
- Integrate with ProjectEditDialog for editing project details
Co-authored-by: Jovines
---
.../chat/MobileSessionStatusBar.tsx | 384 +++++++++++++++++-
1 file changed, 371 insertions(+), 13 deletions(-)
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}
+ />
+ )}
);
}